|
70667
|
2539
|
0
|
2026-05-22T10:28:41.604557+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779445721604_m2.jpg...
|
Firefox
|
[JY-20813] Twilio number is not released when Voic [JY-20813] Twilio number is not released when Voice role is removed from user - Jira — Work...
|
1
|
jiminny.atlassian.net/browse/JY-20813?atlOrigin=ey jiminny.atlassian.net/browse/JY-20813?atlOrigin=eyJpIjoiZGEwYThkZjQ5YjI4NGUyOTkyNjZiYmU0YmJmNWU0MmEiLCJwIjoiamlyYS1zbGFjay1pbnQifQ&issueKey=JY-20813&subProduct=jira-software...
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
[JY-20963] Fix foreign key violation when matching [JY-20963] Fix foreign key violation when matching activities to deleted Salesforce opportunities - Jira
[JY-20963] Fix foreign key violation when matching activities to deleted Salesforce opportunities - Jira
[JY-20912] Fallback mechanism for users with active SF tokens for CRM Matching - Jira
[JY-20912] Fallback mechanism for users with active SF tokens for CRM Matching - Jira
JY-20915 add alias for EU by LakyLak · Pull Request #12105 · jiminny/app
JY-20915 add alias for EU by LakyLak · Pull Request #12105 · jiminny/app
Service-Desk - Queues - Platform team - Service space - Jira
Service-Desk - Queues - Platform team - Service space - Jira
JY-20676 delete AJ reports related objects by LakyLak · Pull Request #12098 · jiminny/app
JY-20676 delete AJ reports related objects by LakyLak · Pull Request #12098 · jiminny/app
JY-20915 add alias for EU by LakyLak · Pull Request #12105 · jiminny/app
JY-20915 add alias for EU by LakyLak · Pull Request #12105 · jiminny/app
Pipelines - jiminny/app
Pipelines - jiminny/app
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {"status":"error","message":"You have reached your secondly limit.","errorType":"RATE_LIMIT
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {"status":"error","message":"You have reached your secondly limit.","errorType":"RATE_LIMIT
Jiminny
Jiminny
[JY-20879] Enable users to use their new activity types - Jira
[JY-20879] Enable users to use their new activity types - Jira
Sentry error
Illuminate\Database\QueryException: SQLSTATE[23000]: Integrity constraint violation: 1452 Cannot add or update a child row: a foreign key constraint fails (`jiminny`.`activities`, CONSTRAINT `activities_opportunity_id_foreign` FOREIGN KEY (`opportunity_id`
Illuminate\Database\QueryException: SQLSTATE[23000]: Integrity constraint violation: 1452 Cannot add or update a child row: a foreign key constraint fails (`jiminny`.`activities`, CONSTRAINT `activities_opportunity_id_foreign` FOREIGN KEY (`opportunity_id`
CloudWatch | eu-west-1
CloudWatch | eu-west-1
JY-20842 add delete events for es by Vasil-Jiminny · Pull Request #12120 · jiminny/app
JY-20842 add delete events for es by Vasil-Jiminny · Pull Request #12120 · jiminny/app
[JY-20813] Twilio number is not released when Voice role is removed from user - Jira
[JY-20813] Twilio number is not released when Voice role is removed from user - Jira
Close tab
New Tab
Customize sidebar
Open Google Gemini (⌃X)
Tabs from other devices
Open history (⇧⌘H)
Open bookmarks (⌘B)
Skip to:
Sidebar
Sidebar
Top Bar
Top Bar
Main Content
Main Content
Collapse sidebar [
Collapse sidebar [
Switch sites or apps
Switch sites or apps
Go to your Jira homepage
Search, press enter to navigate to advanced search with your text query
Create
Create
Rovo Ask Rovo
Ask Rovo
8 Notifications
8 Notifications
Help
Help
Settings
Settings
[EMAIL]
[EMAIL]
For you
For you
Recent
Recent
Starred
Starred
Apps
Apps
More actions for Apps
More actions for Apps
Spaces
Spaces
Create space
Create space
More actions for spaces
More actions for spaces
Recent
Jiminny (New)
Jiminny (New)
Jiminny (New)
Create board
Create board
More actions for Jiminny (New)
More actions for Jiminny (New)
Platform Team
Platform Team
Board actions
Board actions
Capture Team
Capture Team
Board actions
Board actions
Enterprise Stability Issues 🤕
Enterprise Stability Issues 🤕
Board actions
Board actions
Processing Team
Processing Team
Board actions
Board actions
SE Kanban
SE Kanban
Board actions
Board actions
Service-Desk
Service-Desk
More actions for Service-Desk
More actions for Service-Desk
More spaces
More spaces
Filters
Filters
More actions for Filters
More actions for Filters
Dashboards
Dashboards
Create dashboard
Create dashboard
More actions for Dashboards
More actions for Dashboards
Operations
Operations
More actions for Operations
More actions for Operations
Confluence , (opens new window)
Confluence
, (opens new window)
Teams , (opens new window)
Teams
, (opens new window)
open menu
open menu
Customise sidebar
Customise sidebar
Resize side navigation panel
Spaces
Spaces
/
Jiminny (New) Jiminny (New)
Jiminny (New)
/
Epic - Change parent
JY-12249
JY-12249
/
Bug - Change work type
JY-20813
JY-20813
Copy link
Twilio number is not released when Voice role is removed from user- Summary, edit
Twilio number is not released when Voice role is removed from user
Twilio number is not released when Voice role is removed from user
Add or create work related to this Bug
Add or create work related to this Bug
View app actions
View app actions
Collapse Key details Key details
Collapse Key details
Collapse Key details
Key details
Description
Description
Edit Description, edit
Precondition:
User “A” has
Recorder & Voice
role and has an assigned Twilio softphone number.
Steps to reproduce:
Log in to Jiminny as an admin user.
Navigate to Organization Settings > User page
/settings/organization/members
Remove the Voice role from user “A”
Check the assigned number in Twilio
Expected:
The assigned phone number should be automatically released in Twilio.
Observed:
The phone number is
not released
in Twilio.
Note:
The issue appears to be that the number is removed locally but not released in Twilio.
users.softphone_number
is set to NULL ==> CORRECT
the related record in
phone_numbers
table is deleted ==> CORRECT
Steps to reproduce
Steps to reproduce
More information about
Edit Steps to reproduce, edit
None
Actual outcome
More information about
Edit Actual outcome
Add text
Expected outcome
More information about
Edit Expected outcome
Add text
Subtasks
Subtasks
Add subtask
Add subtask
Linked work items
Linked work items
Add linked work item
Add linked work item
Activity
Activity
All
All
Comments
Comments
History
History
Work log
Work log
Newest first Newest first
Newest first
Add a comment…
Who is working on this...?
Who is working on this...?
Status update...
Status update...
Thanks...
Thanks...
Pro tip:
press
M
to comment
Resize work item view side panel
Watch options: You are not watching this issue, 1 person watching
1
Share
Share
Actions
Actions
Backlog - Change status
Backlog
Automation
Automation
Improve Bug
Improve Bug
Details
Details
Details
Assignee
Assignee Pin to top. Only you can see pinned fields.
Unassigned- edit Assignee
Unassigned
Assign to me
Reporter
Reporter Pin to top. Only you can see pinned fields.
Aneliya Angelova- edit Reporter
More information about Aneliya Angelova...
|
[{"role":"AXRadioButton","text [{"role":"AXRadioButton","text":"[JY-20963] Fix foreign key violation when matching activities to deleted Salesforce opportunities - Jira","depth":4,"bounds":{"left":0.0,"top":0.0518755,"width":0.07962101,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"[JY-20963] Fix foreign key violation when matching activities to deleted Salesforce opportunities - Jira","depth":5,"bounds":{"left":0.013297873,"top":0.06304868,"width":0.17902261,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"[JY-20912] Fallback mechanism for users with active SF tokens for CRM Matching - Jira","depth":4,"bounds":{"left":0.0,"top":0.08459697,"width":0.07962101,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"[JY-20912] Fallback mechanism for users with active SF tokens for CRM Matching - Jira","depth":5,"bounds":{"left":0.013297873,"top":0.09577015,"width":0.15259309,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"JY-20915 add alias for EU by LakyLak · Pull Request #12105 · jiminny/app","depth":4,"bounds":{"left":0.0,"top":0.11731844,"width":0.07962101,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"JY-20915 add alias for EU by LakyLak · Pull Request #12105 · jiminny/app","depth":5,"bounds":{"left":0.013297873,"top":0.12849163,"width":0.12699468,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Service-Desk - Queues - Platform team - Service space - Jira","depth":4,"bounds":{"left":0.0,"top":0.15003991,"width":0.07962101,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Service-Desk - Queues - Platform team - Service space - Jira","depth":5,"bounds":{"left":0.013297873,"top":0.16121309,"width":0.10721409,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"JY-20676 delete AJ reports related objects by LakyLak · Pull Request #12098 · jiminny/app","depth":4,"bounds":{"left":0.0,"top":0.18276137,"width":0.07962101,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"JY-20676 delete AJ reports related objects by LakyLak · Pull Request #12098 · jiminny/app","depth":5,"bounds":{"left":0.013297873,"top":0.19393456,"width":0.15791224,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"JY-20915 add alias for EU by LakyLak · Pull Request #12105 · jiminny/app","depth":4,"bounds":{"left":0.0,"top":0.21548285,"width":0.07962101,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"JY-20915 add alias for EU by LakyLak · Pull Request #12105 · jiminny/app","depth":5,"bounds":{"left":0.013297873,"top":0.22665602,"width":0.12699468,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Pipelines - jiminny/app","depth":4,"bounds":{"left":0.0,"top":0.2482043,"width":0.07962101,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Pipelines - jiminny/app","depth":5,"bounds":{"left":0.013297873,"top":0.25937748,"width":0.039228722,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"SevenShores\\Hubspot\\Exceptions\\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {\"status\":\"error\",\"message\":\"You have reached your secondly limit.\",\"errorType\":\"RATE_LIMIT","depth":4,"bounds":{"left":0.0,"top":0.28092578,"width":0.07962101,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"SevenShores\\Hubspot\\Exceptions\\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {\"status\":\"error\",\"message\":\"You have reached your secondly limit.\",\"errorType\":\"RATE_LIMIT","depth":5,"bounds":{"left":0.013297873,"top":0.29209897,"width":0.4644282,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Jiminny","depth":4,"bounds":{"left":0.0,"top":0.31364724,"width":0.07962101,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Jiminny","depth":5,"bounds":{"left":0.013297873,"top":0.32482043,"width":0.013131649,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"[JY-20879] Enable users to use their new activity types - Jira","depth":4,"bounds":{"left":0.0,"top":0.3463687,"width":0.07962101,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"[JY-20879] Enable users to use their new activity types - Jira","depth":5,"bounds":{"left":0.013297873,"top":0.3575419,"width":0.106715426,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Sentry error","depth":4,"bounds":{"left":0.0028257978,"top":0.38427773,"width":0.024601065,"height":0.01915403},"on_screen":true,"role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":true},{"role":"AXRadioButton","text":"Illuminate\\Database\\QueryException: SQLSTATE[23000]: Integrity constraint violation: 1452 Cannot add or update a child row: a foreign key constraint fails (`jiminny`.`activities`, CONSTRAINT `activities_opportunity_id_foreign` FOREIGN KEY (`opportunity_id`","depth":4,"bounds":{"left":0.0028257978,"top":0.40782124,"width":0.07679521,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Illuminate\\Database\\QueryException: SQLSTATE[23000]: Integrity constraint violation: 1452 Cannot add or update a child row: a foreign key constraint fails (`jiminny`.`activities`, CONSTRAINT `activities_opportunity_id_foreign` FOREIGN KEY (`opportunity_id`","depth":5,"bounds":{"left":0.015957447,"top":0.41899443,"width":0.45362368,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"CloudWatch | eu-west-1","depth":4,"bounds":{"left":0.0028257978,"top":0.4405427,"width":0.07679521,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"CloudWatch | eu-west-1","depth":5,"bounds":{"left":0.015957447,"top":0.4517159,"width":0.041722074,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"JY-20842 add delete events for es by Vasil-Jiminny · Pull Request #12120 · jiminny/app","depth":4,"bounds":{"left":0.0,"top":0.47326416,"width":0.07962101,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"JY-20842 add delete events for es by Vasil-Jiminny · Pull Request #12120 · jiminny/app","depth":5,"bounds":{"left":0.013297873,"top":0.48443735,"width":0.15109707,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"[JY-20813] Twilio number is not released when Voice role is removed from user - Jira","depth":4,"bounds":{"left":0.0,"top":0.5059856,"width":0.07962101,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":true},{"role":"AXStaticText","text":"[JY-20813] Twilio number is not released when Voice role is removed from user - Jira","depth":5,"bounds":{"left":0.013297873,"top":0.5171588,"width":0.14793883,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Close tab","depth":5,"bounds":{"left":0.06732048,"top":0.5131684,"width":0.007978723,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"New Tab","depth":4,"bounds":{"left":0.0028257978,"top":0.5403033,"width":0.07413564,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Customize sidebar","depth":6,"bounds":{"left":0.0028257978,"top":0.97007185,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Open Google Gemini (⌃X)","depth":6,"bounds":{"left":0.013796543,"top":0.97007185,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Tabs from other devices","depth":6,"bounds":{"left":0.024933511,"top":0.97007185,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Open history (⇧⌘H)","depth":6,"bounds":{"left":0.036070477,"top":0.97007185,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Open bookmarks (⌘B)","depth":6,"bounds":{"left":0.04720745,"top":0.97007185,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Skip to:","depth":9,"bounds":{"left":0.090259306,"top":0.07861133,"width":0.016954787,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Sidebar","depth":10,"bounds":{"left":0.090259306,"top":0.097765364,"width":0.016954787,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Sidebar","depth":11,"bounds":{"left":0.090259306,"top":0.097765364,"width":0.016954787,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Top Bar","depth":10,"bounds":{"left":0.090259306,"top":0.11691939,"width":0.016954787,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Top Bar","depth":11,"bounds":{"left":0.090259306,"top":0.11691939,"width":0.016954787,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Main Content","depth":10,"bounds":{"left":0.090259306,"top":0.13607343,"width":0.029421542,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Main Content","depth":11,"bounds":{"left":0.090259306,"top":0.13607343,"width":0.029421542,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Collapse sidebar [","depth":9,"bounds":{"left":0.08361037,"top":0.057861134,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Collapse sidebar [","depth":11,"bounds":{"left":0.0887633,"top":0.06344773,"width":0.039727394,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"Switch sites or apps","depth":10,"bounds":{"left":0.095578454,"top":0.057861134,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Switch sites or apps","depth":12,"bounds":{"left":0.10073138,"top":0.06344773,"width":0.044215426,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Go to your Jira homepage","depth":9,"bounds":{"left":0.10887633,"top":0.057861134,"width":0.029421542,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXComboBox","text":"Search, press enter to navigate to advanced search with your text query","depth":11,"bounds":{"left":0.40475398,"top":0.06264964,"width":0.24268617,"height":0.015961692},"on_screen":true,"help_text":"","placeholder":"Search","role_description":"combo box","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Create","depth":10,"bounds":{"left":0.65575135,"top":0.057861134,"width":0.030086435,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Create","depth":12,"bounds":{"left":0.66705453,"top":0.06384677,"width":0.014793883,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Rovo Ask Rovo","depth":12,"bounds":{"left":0.91223407,"top":0.057861134,"width":0.035904255,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Ask Rovo","depth":14,"bounds":{"left":0.92353725,"top":0.06384677,"width":0.020611702,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"8 Notifications","depth":12,"bounds":{"left":0.9494681,"top":0.057861134,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"8 Notifications","depth":14,"bounds":{"left":0.954621,"top":0.06344773,"width":0.031914894,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Help","depth":12,"bounds":{"left":0.96143615,"top":0.057861134,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Help","depth":14,"bounds":{"left":0.9665891,"top":0.06344773,"width":0.010139627,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"Settings","depth":12,"bounds":{"left":0.9734042,"top":0.057861134,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Settings","depth":14,"bounds":{"left":0.97855717,"top":0.06344773,"width":0.017952127,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"lukas.kovalik@jiminny.com","depth":12,"bounds":{"left":0.98537236,"top":0.057861134,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"lukas.kovalik@jiminny.com","depth":14,"bounds":{"left":0.99052525,"top":0.06344773,"width":0.009474754,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"For you","depth":12,"bounds":{"left":0.08361037,"top":0.09976058,"width":0.071476065,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"For you","depth":15,"bounds":{"left":0.09424867,"top":0.10574621,"width":0.01662234,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Recent","depth":12,"bounds":{"left":0.08361037,"top":0.12529927,"width":0.071476065,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Recent","depth":15,"bounds":{"left":0.09424867,"top":0.13128492,"width":0.015458777,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Starred","depth":12,"bounds":{"left":0.08361037,"top":0.15083799,"width":0.071476065,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Starred","depth":15,"bounds":{"left":0.09424867,"top":0.15682362,"width":0.016456118,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Apps","depth":12,"bounds":{"left":0.08361037,"top":0.1763767,"width":0.071476065,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Apps","depth":15,"bounds":{"left":0.09424867,"top":0.18236233,"width":0.011635638,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"More actions for Apps","depth":13,"bounds":{"left":0.15309176,"top":0.17956904,"width":0.0039893617,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"More actions for Apps","depth":15,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Spaces","depth":12,"bounds":{"left":0.08361037,"top":0.2019154,"width":0.071476065,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":true},{"role":"AXStaticText","text":"Spaces","depth":15,"bounds":{"left":0.09424867,"top":0.20790103,"width":0.016456118,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Create space","depth":13,"bounds":{"left":0.13646941,"top":0.20510775,"width":0.007978723,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Create space","depth":15,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"More actions for spaces","depth":13,"bounds":{"left":0.14577793,"top":0.20510775,"width":0.007978723,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"More actions for spaces","depth":15,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Recent","depth":16,"bounds":{"left":0.08959442,"top":0.23423783,"width":0.013464096,"height":0.011971269},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Jiminny (New)","depth":17,"bounds":{"left":0.08759973,"top":0.2529928,"width":0.0674867,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Jiminny (New)","depth":20,"bounds":{"left":0.09823803,"top":0.25897846,"width":0.032081116,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Jiminny (New)","depth":18,"bounds":{"left":0.08892952,"top":0.25618514,"width":0.007978723,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":true},{"role":"AXMenuButton","text":"Create board","depth":18,"bounds":{"left":0.13646941,"top":0.25618514,"width":0.007978723,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Create board","depth":20,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"More actions for Jiminny (New)","depth":18,"bounds":{"left":0.14577793,"top":0.25618514,"width":0.007978723,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"More actions for Jiminny (New)","depth":20,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Platform Team","depth":19,"bounds":{"left":0.09158909,"top":0.27853152,"width":0.06349734,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Platform Team","depth":22,"bounds":{"left":0.1022274,"top":0.28451717,"width":0.032247342,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"Board actions","depth":20,"bounds":{"left":0.15309176,"top":0.28172386,"width":0.0039893617,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Board actions","depth":22,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Capture Team","depth":19,"bounds":{"left":0.09158909,"top":0.30407023,"width":0.06349734,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Capture Team","depth":22,"bounds":{"left":0.1022274,"top":0.31005585,"width":0.03125,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"Board actions","depth":20,"bounds":{"left":0.15309176,"top":0.30726257,"width":0.0039893617,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Board actions","depth":22,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Enterprise Stability Issues 🤕","depth":19,"bounds":{"left":0.09158909,"top":0.32960895,"width":0.06349734,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Enterprise Stability Issues 🤕","depth":22,"bounds":{"left":0.1022274,"top":0.33559456,"width":0.050531916,"height":0.030726258},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"Board actions","depth":20,"bounds":{"left":0.15309176,"top":0.33280128,"width":0.0039893617,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Board actions","depth":22,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Processing Team","depth":19,"bounds":{"left":0.09158909,"top":0.35514766,"width":0.06349734,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Processing Team","depth":22,"bounds":{"left":0.1022274,"top":0.36113328,"width":0.038231384,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"Board actions","depth":20,"bounds":{"left":0.15309176,"top":0.35834,"width":0.0039893617,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Board actions","depth":22,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"SE Kanban","depth":19,"bounds":{"left":0.09158909,"top":0.38068634,"width":0.06349734,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"SE Kanban","depth":22,"bounds":{"left":0.1022274,"top":0.386672,"width":0.024102394,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"Board actions","depth":20,"bounds":{"left":0.15309176,"top":0.38387868,"width":0.0039893617,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Board actions","depth":22,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Service-Desk","depth":17,"bounds":{"left":0.08759973,"top":0.40622506,"width":0.0674867,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Service-Desk","depth":20,"bounds":{"left":0.09823803,"top":0.4122107,"width":0.03025266,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"More actions for Service-Desk","depth":18,"bounds":{"left":0.15442154,"top":0.4094174,"width":0.0039893617,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"More actions for Service-Desk","depth":20,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"More spaces","depth":17,"bounds":{"left":0.08759973,"top":0.43176377,"width":0.0674867,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"More spaces","depth":20,"bounds":{"left":0.09823803,"top":0.43774942,"width":0.028756648,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Filters","depth":12,"bounds":{"left":0.08361037,"top":0.45730248,"width":0.071476065,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Filters","depth":15,"bounds":{"left":0.09424867,"top":0.4632881,"width":0.013796543,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"More actions for Filters","depth":13,"bounds":{"left":0.15309176,"top":0.46049482,"width":0.0039893617,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"More actions for Filters","depth":15,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Dashboards","depth":12,"bounds":{"left":0.08361037,"top":0.4828412,"width":0.071476065,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Dashboards","depth":15,"bounds":{"left":0.09424867,"top":0.4888268,"width":0.026761968,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Create dashboard","depth":13,"bounds":{"left":0.15508644,"top":0.48603353,"width":0.007978723,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Create dashboard","depth":15,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"More actions for Dashboards","depth":13,"bounds":{"left":0.16240026,"top":0.48603353,"width":0.0039893617,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"More actions for Dashboards","depth":15,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Operations","depth":12,"bounds":{"left":0.08361037,"top":0.5083799,"width":0.071476065,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Operations","depth":15,"bounds":{"left":0.09424867,"top":0.5143655,"width":0.02443484,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"More actions for Operations","depth":13,"bounds":{"left":0.15309176,"top":0.51157224,"width":0.0039893617,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"More actions for Operations","depth":15,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Confluence , (opens new window)","depth":13,"bounds":{"left":0.08361037,"top":0.5434956,"width":0.071476065,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Confluence","depth":17,"bounds":{"left":0.09424867,"top":0.5494813,"width":0.025764627,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":", (opens new window)","depth":15,"bounds":{"left":0.08361037,"top":0.55706304,"width":0.04837101,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Teams , (opens new window)","depth":13,"bounds":{"left":0.08361037,"top":0.56903434,"width":0.071476065,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Teams","depth":17,"bounds":{"left":0.09424867,"top":0.57501996,"width":0.014793883,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":", (opens new window)","depth":15,"bounds":{"left":0.08361037,"top":0.5826017,"width":0.04837101,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"open menu","depth":14,"bounds":{"left":0.14378324,"top":0.57222664,"width":0.0039893617,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"open menu","depth":16,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Customise sidebar","depth":12,"bounds":{"left":0.08361037,"top":0.60415006,"width":0.071476065,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Customise sidebar","depth":15,"bounds":{"left":0.09424867,"top":0.6101357,"width":0.04155585,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Resize side navigation panel","depth":13,"bounds":{"left":0.2109375,"top":0.0981644,"width":0.062333778,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Spaces","depth":15,"bounds":{"left":0.27260637,"top":0.10933759,"width":0.013962766,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Spaces","depth":17,"bounds":{"left":0.27260637,"top":0.11292897,"width":0.013962766,"height":0.011971269},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"/","depth":15,"bounds":{"left":0.2883976,"top":0.11173184,"width":0.0016622341,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Jiminny (New) Jiminny (New)","depth":15,"bounds":{"left":0.29388297,"top":0.10933759,"width":0.034408245,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Jiminny (New)","depth":17,"bounds":{"left":0.3011968,"top":0.11292897,"width":0.027094414,"height":0.011971269},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"/","depth":15,"bounds":{"left":0.33011967,"top":0.11173184,"width":0.0016622341,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"Epic - Change parent","depth":15,"bounds":{"left":0.3336104,"top":0.10933759,"width":0.007978723,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"JY-12249","depth":15,"bounds":{"left":0.3415891,"top":0.10933759,"width":0.017952127,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"JY-12249","depth":17,"bounds":{"left":0.3415891,"top":0.11292897,"width":0.017952127,"height":0.011971269},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"/","depth":15,"bounds":{"left":0.36136967,"top":0.11173184,"width":0.0016622341,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"Bug - Change work type","depth":15,"bounds":{"left":0.3648604,"top":0.10933759,"width":0.007978723,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"JY-20813","depth":15,"bounds":{"left":0.3728391,"top":0.10933759,"width":0.017952127,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"JY-20813","depth":17,"bounds":{"left":0.3728391,"top":0.11292897,"width":0.017952127,"height":0.011971269},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Copy link","depth":16,"bounds":{"left":0.38946143,"top":0.11213089,"width":0.005319149,"height":0.012769354},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"Twilio number is not released when Voice role is removed from user- Summary, edit","depth":11,"on_screen":false,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXHeading","text":"Twilio number is not released when Voice role is removed from user","depth":11,"bounds":{"left":0.27327126,"top":0.1396648,"width":0.25149602,"height":0.022346368},"on_screen":true,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Twilio number is not released when Voice role is removed from user","depth":12,"bounds":{"left":0.27327126,"top":0.13926576,"width":0.25149602,"height":0.023543496},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"Add or create work related to this Bug","depth":12,"bounds":{"left":0.27260637,"top":0.17158818,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Add or create work related to this Bug","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"View app actions","depth":12,"bounds":{"left":0.28590426,"top":0.17158818,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"View app actions","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXHeading","text":"Collapse Key details Key details","depth":11,"bounds":{"left":0.26462767,"top":0.20989625,"width":0.38314494,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXButton","text":"Collapse Key details","depth":13,"bounds":{"left":0.2632979,"top":0.21308859,"width":0.007978723,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Collapse Key details","depth":15,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Key details","depth":14,"bounds":{"left":0.27260637,"top":0.2150838,"width":0.028091755,"height":0.01556265},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXHeading","text":"Description","depth":12,"bounds":{"left":0.27260637,"top":0.2386273,"width":0.02543218,"height":0.014764565},"on_screen":true,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Description","depth":13,"bounds":{"left":0.27260637,"top":0.23902634,"width":0.02543218,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Edit Description, edit","depth":13,"on_screen":false,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Precondition:","depth":15,"bounds":{"left":0.27327126,"top":0.26097366,"width":0.030418882,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"User “A” has","depth":14,"bounds":{"left":0.27327126,"top":0.2897047,"width":0.028590426,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Recorder & Voice","depth":15,"bounds":{"left":0.30319148,"top":0.29090184,"width":0.0390625,"height":0.0131683955},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"role and has an assigned Twilio softphone number.","depth":14,"bounds":{"left":0.34458113,"top":0.2897047,"width":0.11153591,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Steps to reproduce:","depth":15,"bounds":{"left":0.27327126,"top":0.31843576,"width":0.044714097,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Log in to Jiminny as an admin user.","depth":16,"bounds":{"left":0.28125,"top":0.3471668,"width":0.07712766,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Navigate to Organization Settings > User page","depth":16,"bounds":{"left":0.28125,"top":0.36951315,"width":0.101894945,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"/settings/organization/members","depth":17,"bounds":{"left":0.28241357,"top":0.38986433,"width":0.07330452,"height":0.0131683955},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Remove the Voice role from user “A”","depth":16,"bounds":{"left":0.28125,"top":0.41101357,"width":0.07962101,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Check the assigned number in Twilio","depth":16,"bounds":{"left":0.28125,"top":0.43335995,"width":0.080784574,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Expected:","depth":15,"bounds":{"left":0.27327126,"top":0.49082202,"width":0.022772606,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"The assigned phone number should be automatically released in Twilio.","depth":14,"bounds":{"left":0.27327126,"top":0.509976,"width":0.15708111,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Observed:","depth":15,"bounds":{"left":0.27327126,"top":0.5387071,"width":0.023769947,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"The phone number is","depth":14,"bounds":{"left":0.27327126,"top":0.55786115,"width":0.047872342,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"not released","depth":15,"bounds":{"left":0.32114363,"top":0.55786115,"width":0.02825798,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"in Twilio.","depth":14,"bounds":{"left":0.3494016,"top":0.55786115,"width":0.020611702,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Note:","depth":15,"bounds":{"left":0.27327126,"top":0.61532325,"width":0.013464096,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"The issue appears to be that the number is removed locally but not released in Twilio.","depth":14,"bounds":{"left":0.2867354,"top":0.61532325,"width":0.1875,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"users.softphone_number","depth":17,"bounds":{"left":0.28241357,"top":0.6452514,"width":0.053690158,"height":0.0131683955},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"is set to NULL ==> CORRECT","depth":16,"bounds":{"left":0.33743352,"top":0.6440543,"width":0.06299867,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"the related record in","depth":16,"bounds":{"left":0.28125,"top":0.6664006,"width":0.045545213,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"phone_numbers","depth":17,"bounds":{"left":0.32795876,"top":0.6675978,"width":0.03174867,"height":0.0131683955},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"table is deleted ==> CORRECT","depth":16,"bounds":{"left":0.36103722,"top":0.6664006,"width":0.06582447,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXHeading","text":"Steps to reproduce","depth":12,"bounds":{"left":0.27260637,"top":0.7035116,"width":0.042386968,"height":0.015163607},"on_screen":true,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Steps to reproduce","depth":13,"bounds":{"left":0.27260637,"top":0.70430964,"width":0.042386968,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"More information about","depth":12,"bounds":{"left":0.31632313,"top":0.7055068,"width":0.005319149,"height":0.012769354},"on_screen":true,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Edit Steps to reproduce, edit","depth":12,"on_screen":false,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"None","depth":13,"bounds":{"left":0.27260637,"top":0.7274541,"width":0.011635638,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Actual outcome","depth":13,"bounds":{"left":0.27260637,"top":0.7721468,"width":0.03474069,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"More information about","depth":12,"bounds":{"left":0.31133643,"top":0.7729449,"width":0.005319149,"height":0.012769354},"on_screen":true,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Edit Actual outcome","depth":13,"bounds":{"left":0.42270613,"top":0.77893054,"width":0.0003324468,"height":0.0007980846},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Add text","depth":14,"bounds":{"left":0.42270613,"top":0.7721468,"width":0.018450798,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Expected outcome","depth":13,"bounds":{"left":0.27260637,"top":0.8104549,"width":0.04155585,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"More information about","depth":12,"bounds":{"left":0.3181516,"top":0.811253,"width":0.005319149,"height":0.012769354},"on_screen":true,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Edit Expected outcome","depth":13,"bounds":{"left":0.42270613,"top":0.8172386,"width":0.0003324468,"height":0.0007980846},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Add text","depth":14,"bounds":{"left":0.42270613,"top":0.8104549,"width":0.018450798,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXHeading","text":"Subtasks","depth":11,"bounds":{"left":0.27260637,"top":0.8639266,"width":0.023936171,"height":0.015961692},"on_screen":true,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Subtasks","depth":12,"bounds":{"left":0.27260637,"top":0.8639266,"width":0.023936171,"height":0.01556265},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Add subtask","depth":12,"bounds":{"left":0.26861703,"top":0.88467675,"width":0.035405584,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Add subtask","depth":14,"bounds":{"left":0.27260637,"top":0.8902634,"width":0.027426861,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXHeading","text":"Linked work items","depth":11,"bounds":{"left":0.27260637,"top":0.9293695,"width":0.04654255,"height":0.015961692},"on_screen":true,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Linked work items","depth":12,"bounds":{"left":0.27260637,"top":0.9293695,"width":0.04654255,"height":0.01556265},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Add linked work item","depth":12,"bounds":{"left":0.26861703,"top":0.9485235,"width":0.054022606,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Add linked work item","depth":14,"bounds":{"left":0.27260637,"top":0.95411015,"width":0.046043884,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXHeading","text":"Activity","depth":13,"bounds":{"left":0.27260637,"top":0.9932163,"width":0.019780586,"height":0.006783724},"on_screen":true,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Activity","depth":14,"bounds":{"left":0.27260637,"top":0.9932163,"width":0.019780586,"height":0.006783724},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"All","depth":14,"bounds":{"left":0.27360374,"top":1.0,"width":0.013962766,"height":-0.014764547},"on_screen":false,"help_text":"","role_description":"radio button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"All","depth":16,"bounds":{"left":0.27792552,"top":1.0,"width":0.005319149,"height":-0.017956853},"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Comments","depth":14,"bounds":{"left":0.28823137,"top":1.0,"width":0.032413565,"height":-0.014764547},"on_screen":false,"help_text":"","role_description":"radio button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Comments","depth":16,"bounds":{"left":0.2925532,"top":1.0,"width":0.023769947,"height":-0.017956853},"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"History","depth":14,"bounds":{"left":0.32130983,"top":1.0,"width":0.02443484,"height":-0.014764547},"on_screen":false,"help_text":"","role_description":"radio button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"History","depth":16,"bounds":{"left":0.32563165,"top":1.0,"width":0.015791224,"height":-0.017956853},"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Work log","depth":14,"bounds":{"left":0.3464096,"top":1.0,"width":0.02825798,"height":-0.014764547},"on_screen":false,"help_text":"","role_description":"radio button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Work log","depth":16,"bounds":{"left":0.35073137,"top":1.0,"width":0.019614361,"height":-0.017956853},"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Newest first Newest first","depth":13,"bounds":{"left":0.6397939,"top":1.0,"width":0.007978723,"height":-0.015562654},"on_screen":false,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Newest first","depth":15,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Add a comment…","depth":15,"bounds":{"left":0.28656915,"top":1.0,"width":0.36053857,"height":-0.00758183},"on_screen":false,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"Who is working on this...?","depth":17,"bounds":{"left":0.2925532,"top":1.0,"width":0.06466091,"height":-0.045889854},"on_screen":false,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Who is working on this...?","depth":19,"bounds":{"left":0.29654256,"top":1.0,"width":0.05668218,"height":-0.048284173},"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Status update...","depth":17,"bounds":{"left":0.35854387,"top":1.0,"width":0.04305186,"height":-0.045889854},"on_screen":false,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Status update...","depth":19,"bounds":{"left":0.36253324,"top":1.0,"width":0.03507314,"height":-0.048284173},"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Thanks...","depth":17,"bounds":{"left":0.40292552,"top":1.0,"width":0.028590426,"height":-0.045889854},"on_screen":false,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Thanks...","depth":19,"bounds":{"left":0.4069149,"top":1.0,"width":0.020611702,"height":-0.048284173},"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Pro tip:","depth":16,"bounds":{"left":0.28590426,"top":1.0,"width":0.013796543,"height":-0.0877893},"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"press","depth":15,"bounds":{"left":0.2997008,"top":1.0,"width":0.012632979,"height":-0.0877893},"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"M","depth":17,"bounds":{"left":0.31366357,"top":1.0,"width":0.0033244682,"height":-0.0885874},"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"to comment","depth":15,"bounds":{"left":0.31831783,"top":1.0,"width":0.023603724,"height":-0.0877893},"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Resize work item view side panel","depth":12,"bounds":{"left":0.7077792,"top":0.0981644,"width":0.07280585,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Watch options: You are not watching this issue, 1 person watching","depth":13,"bounds":{"left":0.84707445,"top":0.10694334,"width":0.017287234,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"1","depth":17,"bounds":{"left":0.85837764,"top":0.11292897,"width":0.0019946808,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"Share","depth":14,"bounds":{"left":0.86702126,"top":0.10694334,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Share","depth":16,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"Actions","depth":14,"bounds":{"left":0.8803192,"top":0.10694334,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Actions","depth":16,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Backlog - Change status","depth":11,"bounds":{"left":0.66240025,"top":0.14365523,"width":0.031914894,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Backlog","depth":13,"bounds":{"left":0.66638964,"top":0.14964086,"width":0.017952127,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"Automation","depth":11,"bounds":{"left":0.69697475,"top":0.14365523,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Automation","depth":13,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"Improve Bug","depth":11,"bounds":{"left":0.7102726,"top":0.14365523,"width":0.042054523,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Improve Bug","depth":13,"bounds":{"left":0.7195811,"top":0.14964086,"width":0.028756648,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Details","depth":14,"bounds":{"left":0.6653923,"top":0.18754987,"width":0.21725398,"height":0.022346368},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":true},{"role":"AXHeading","text":"Details","depth":17,"bounds":{"left":0.6747008,"top":0.19074222,"width":0.01861702,"height":0.015961692},"on_screen":true,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Details","depth":18,"bounds":{"left":0.6747008,"top":0.19114126,"width":0.017287234,"height":0.01556265},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Assignee","depth":12,"bounds":{"left":0.6690492,"top":0.23982441,"width":0.020611702,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXCheckBox","text":"Assignee Pin to top. Only you can see pinned fields.","depth":11,"bounds":{"left":0.69232047,"top":0.24022347,"width":0.005319149,"height":0.012769354},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"Unassigned- edit Assignee","depth":12,"bounds":{"left":0.75615025,"top":0.23663208,"width":0.0003324468,"height":0.0007980846},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Unassigned","depth":13,"bounds":{"left":0.76678854,"top":0.23024741,"width":0.025930852,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Assign to me","depth":11,"bounds":{"left":0.75615025,"top":0.2529928,"width":0.1278258,"height":0.015961692},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Reporter","depth":12,"bounds":{"left":0.6690492,"top":0.28451717,"width":0.019281914,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXCheckBox","text":"Reporter Pin to top. Only you can see pinned fields.","depth":11,"bounds":{"left":0.6909907,"top":0.2849162,"width":0.005319149,"height":0.012769354},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"Aneliya Angelova- edit Reporter","depth":12,"bounds":{"left":0.75615025,"top":0.29090184,"width":0.0003324468,"height":0.0007980846},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXMenuButton","text":"More information about Aneliya Angelova","depth":13,"bounds":{"left":0.75615025,"top":0.28172386,"width":0.048537236,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
-8913428991486662021
|
5914765252228240068
|
idle
|
accessibility
|
NULL
|
[JY-20963] Fix foreign key violation when matching [JY-20963] Fix foreign key violation when matching activities to deleted Salesforce opportunities - Jira
[JY-20963] Fix foreign key violation when matching activities to deleted Salesforce opportunities - Jira
[JY-20912] Fallback mechanism for users with active SF tokens for CRM Matching - Jira
[JY-20912] Fallback mechanism for users with active SF tokens for CRM Matching - Jira
JY-20915 add alias for EU by LakyLak · Pull Request #12105 · jiminny/app
JY-20915 add alias for EU by LakyLak · Pull Request #12105 · jiminny/app
Service-Desk - Queues - Platform team - Service space - Jira
Service-Desk - Queues - Platform team - Service space - Jira
JY-20676 delete AJ reports related objects by LakyLak · Pull Request #12098 · jiminny/app
JY-20676 delete AJ reports related objects by LakyLak · Pull Request #12098 · jiminny/app
JY-20915 add alias for EU by LakyLak · Pull Request #12105 · jiminny/app
JY-20915 add alias for EU by LakyLak · Pull Request #12105 · jiminny/app
Pipelines - jiminny/app
Pipelines - jiminny/app
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {"status":"error","message":"You have reached your secondly limit.","errorType":"RATE_LIMIT
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {"status":"error","message":"You have reached your secondly limit.","errorType":"RATE_LIMIT
Jiminny
Jiminny
[JY-20879] Enable users to use their new activity types - Jira
[JY-20879] Enable users to use their new activity types - Jira
Sentry error
Illuminate\Database\QueryException: SQLSTATE[23000]: Integrity constraint violation: 1452 Cannot add or update a child row: a foreign key constraint fails (`jiminny`.`activities`, CONSTRAINT `activities_opportunity_id_foreign` FOREIGN KEY (`opportunity_id`
Illuminate\Database\QueryException: SQLSTATE[23000]: Integrity constraint violation: 1452 Cannot add or update a child row: a foreign key constraint fails (`jiminny`.`activities`, CONSTRAINT `activities_opportunity_id_foreign` FOREIGN KEY (`opportunity_id`
CloudWatch | eu-west-1
CloudWatch | eu-west-1
JY-20842 add delete events for es by Vasil-Jiminny · Pull Request #12120 · jiminny/app
JY-20842 add delete events for es by Vasil-Jiminny · Pull Request #12120 · jiminny/app
[JY-20813] Twilio number is not released when Voice role is removed from user - Jira
[JY-20813] Twilio number is not released when Voice role is removed from user - Jira
Close tab
New Tab
Customize sidebar
Open Google Gemini (⌃X)
Tabs from other devices
Open history (⇧⌘H)
Open bookmarks (⌘B)
Skip to:
Sidebar
Sidebar
Top Bar
Top Bar
Main Content
Main Content
Collapse sidebar [
Collapse sidebar [
Switch sites or apps
Switch sites or apps
Go to your Jira homepage
Search, press enter to navigate to advanced search with your text query
Create
Create
Rovo Ask Rovo
Ask Rovo
8 Notifications
8 Notifications
Help
Help
Settings
Settings
[EMAIL]
[EMAIL]
For you
For you
Recent
Recent
Starred
Starred
Apps
Apps
More actions for Apps
More actions for Apps
Spaces
Spaces
Create space
Create space
More actions for spaces
More actions for spaces
Recent
Jiminny (New)
Jiminny (New)
Jiminny (New)
Create board
Create board
More actions for Jiminny (New)
More actions for Jiminny (New)
Platform Team
Platform Team
Board actions
Board actions
Capture Team
Capture Team
Board actions
Board actions
Enterprise Stability Issues 🤕
Enterprise Stability Issues 🤕
Board actions
Board actions
Processing Team
Processing Team
Board actions
Board actions
SE Kanban
SE Kanban
Board actions
Board actions
Service-Desk
Service-Desk
More actions for Service-Desk
More actions for Service-Desk
More spaces
More spaces
Filters
Filters
More actions for Filters
More actions for Filters
Dashboards
Dashboards
Create dashboard
Create dashboard
More actions for Dashboards
More actions for Dashboards
Operations
Operations
More actions for Operations
More actions for Operations
Confluence , (opens new window)
Confluence
, (opens new window)
Teams , (opens new window)
Teams
, (opens new window)
open menu
open menu
Customise sidebar
Customise sidebar
Resize side navigation panel
Spaces
Spaces
/
Jiminny (New) Jiminny (New)
Jiminny (New)
/
Epic - Change parent
JY-12249
JY-12249
/
Bug - Change work type
JY-20813
JY-20813
Copy link
Twilio number is not released when Voice role is removed from user- Summary, edit
Twilio number is not released when Voice role is removed from user
Twilio number is not released when Voice role is removed from user
Add or create work related to this Bug
Add or create work related to this Bug
View app actions
View app actions
Collapse Key details Key details
Collapse Key details
Collapse Key details
Key details
Description
Description
Edit Description, edit
Precondition:
User “A” has
Recorder & Voice
role and has an assigned Twilio softphone number.
Steps to reproduce:
Log in to Jiminny as an admin user.
Navigate to Organization Settings > User page
/settings/organization/members
Remove the Voice role from user “A”
Check the assigned number in Twilio
Expected:
The assigned phone number should be automatically released in Twilio.
Observed:
The phone number is
not released
in Twilio.
Note:
The issue appears to be that the number is removed locally but not released in Twilio.
users.softphone_number
is set to NULL ==> CORRECT
the related record in
phone_numbers
table is deleted ==> CORRECT
Steps to reproduce
Steps to reproduce
More information about
Edit Steps to reproduce, edit
None
Actual outcome
More information about
Edit Actual outcome
Add text
Expected outcome
More information about
Edit Expected outcome
Add text
Subtasks
Subtasks
Add subtask
Add subtask
Linked work items
Linked work items
Add linked work item
Add linked work item
Activity
Activity
All
All
Comments
Comments
History
History
Work log
Work log
Newest first Newest first
Newest first
Add a comment…
Who is working on this...?
Who is working on this...?
Status update...
Status update...
Thanks...
Thanks...
Pro tip:
press
M
to comment
Resize work item view side panel
Watch options: You are not watching this issue, 1 person watching
1
Share
Share
Actions
Actions
Backlog - Change status
Backlog
Automation
Automation
Improve Bug
Improve Bug
Details
Details
Details
Assignee
Assignee Pin to top. Only you can see pinned fields.
Unassigned- edit Assignee
Unassigned
Assign to me
Reporter
Reporter Pin to top. Only you can see pinned fields.
Aneliya Angelova- edit Reporter
More information about Aneliya Angelova...
|
70665
|
NULL
|
NULL
|
NULL
|
|
70666
|
2538
|
0
|
2026-05-22T10:28:36.650501+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779445716650_m1.jpg...
|
Firefox
|
[JY-20813] Twilio number is not released when Voic [JY-20813] Twilio number is not released when Voice role is removed from user - Jira — Work...
|
1
|
jiminny.atlassian.net/browse/JY-20813?atlOrigin=ey jiminny.atlassian.net/browse/JY-20813?atlOrigin=eyJpIjoiZGEwYThkZjQ5YjI4NGUyOTkyNjZiYmU0YmJmNWU0MmEiLCJwIjoiamlyYS1zbGFjay1pbnQifQ&issueKey=JY-20813&subProduct=jira-software...
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
[JY-20963] Fix foreign key violation when matching [JY-20963] Fix foreign key violation when matching activities to deleted Salesforce opportunities - Jira
[JY-20963] Fix foreign key violation when matching activities to deleted Salesforce opportunities - Jira
[JY-20912] Fallback mechanism for users with active SF tokens for CRM Matching - Jira
[JY-20912] Fallback mechanism for users with active SF tokens for CRM Matching - Jira
JY-20915 add alias for EU by LakyLak · Pull Request #12105 · jiminny/app
JY-20915 add alias for EU by LakyLak · Pull Request #12105 · jiminny/app
Service-Desk - Queues - Platform team - Service space - Jira
Service-Desk - Queues - Platform team - Service space - Jira
JY-20676 delete AJ reports related objects by LakyLak · Pull Request #12098 · jiminny/app
JY-20676 delete AJ reports related objects by LakyLak · Pull Request #12098 · jiminny/app
JY-20915 add alias for EU by LakyLak · Pull Request #12105 · jiminny/app
JY-20915 add alias for EU by LakyLak · Pull Request #12105 · jiminny/app
Pipelines - jiminny/app
Pipelines - jiminny/app
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {"status":"error","message":"You have reached your secondly limit.","errorType":"RATE_LIMIT
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {"status":"error","message":"You have reached your secondly limit.","errorType":"RATE_LIMIT
Jiminny
Jiminny
[JY-20879] Enable users to use their new activity types - Jira
[JY-20879] Enable users to use their new activity types - Jira
Sentry error
Illuminate\Database\QueryException: SQLSTATE[23000]: Integrity constraint violation: 1452 Cannot add or update a child row: a foreign key constraint fails (`jiminny`.`activities`, CONSTRAINT `activities_opportunity_id_foreign` FOREIGN KEY (`opportunity_id`
Illuminate\Database\QueryException: SQLSTATE[23000]: Integrity constraint violation: 1452 Cannot add or update a child row: a foreign key constraint fails (`jiminny`.`activities`, CONSTRAINT `activities_opportunity_id_foreign` FOREIGN KEY (`opportunity_id`
CloudWatch | eu-west-1
CloudWatch | eu-west-1
JY-20842 add delete events for es by Vasil-Jiminny · Pull Request #12120 · jiminny/app
JY-20842 add delete events for es by Vasil-Jiminny · Pull Request #12120 · jiminny/app
[JY-20813] Twilio number is not released when Voice role is removed from user - Jira
[JY-20813] Twilio number is not released when Voice role is removed from user - Jira
Close tab
New Tab
Customize sidebar
Open Google Gemini (⌃X)
Tabs from other devices
Open history (⇧⌘H)
Open bookmarks (⌘B)
Skip to:
Sidebar
Sidebar
Top Bar
Top Bar
Main Content
Main Content
Collapse sidebar [
Collapse sidebar [
Switch sites or apps
Switch sites or apps
Go to your Jira homepage
Search, press enter to navigate to advanced search with your text query
Create
Create
Rovo Ask Rovo
Ask Rovo
8 Notifications
8 Notifications
Help
Help
Settings
Settings
[EMAIL]
[EMAIL]
For you
For you
Recent
Recent
Starred
Starred
Apps
Apps
More actions for Apps
More actions for Apps
Spaces
Spaces
Create space
Create space
More actions for spaces
More actions for spaces
Recent
Jiminny (New)
Jiminny (New)
Jiminny (New)
Create board
Create board
More actions for Jiminny (New)
More actions for Jiminny (New)
Platform Team
Platform Team
Board actions
Board actions
Capture Team
Capture Team
Board actions
Board actions
Enterprise Stability Issues 🤕
Enterprise Stability Issues 🤕
Board actions
Board actions
Processing Team
Processing Team
Board actions
Board actions
SE Kanban
SE Kanban
Board actions
Board actions
Service-Desk
Service-Desk
More actions for Service-Desk
More actions for Service-Desk
More spaces
More spaces
Filters
Filters
More actions for Filters
More actions for Filters
Dashboards
Dashboards
Create dashboard
Create dashboard
More actions for Dashboards
More actions for Dashboards
Operations
Operations
More actions for Operations
More actions for Operations
Confluence , (opens new window)
Confluence
, (opens new window)
Teams , (opens new window)
Teams
, (opens new window)
open menu
open menu
Customise sidebar
Customise sidebar
Resize side navigation panel
Spaces
Spaces
/
Jiminny (New) Jiminny (New)
Jiminny (New)
/
Epic - Change parent
JY-12249
JY-12249
/
Bug - Change work type
JY-20813
JY-20813
Copy link
Twilio number is not released when Voice role is removed from user- Summary, edit
Twilio number is not released when Voice role is removed from user
Twilio number is not released when Voice role is removed from user
Add or create work related to this Bug
Add or create work related to this Bug
View app actions
View app actions
Collapse Key details Key details
Collapse Key details
Collapse Key details
Key details
Description
Description...
|
[{"role":"AXRadioButton","text [{"role":"AXRadioButton","text":"[JY-20963] Fix foreign key violation when matching activities to deleted Salesforce opportunities - Jira","depth":4,"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"[JY-20963] Fix foreign key violation when matching activities to deleted Salesforce opportunities - Jira","depth":5,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"[JY-20912] Fallback mechanism for users with active SF tokens for CRM Matching - Jira","depth":4,"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"[JY-20912] Fallback mechanism for users with active SF tokens for CRM Matching - Jira","depth":5,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"JY-20915 add alias for EU by LakyLak · Pull Request #12105 · jiminny/app","depth":4,"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"JY-20915 add alias for EU by LakyLak · Pull Request #12105 · jiminny/app","depth":5,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Service-Desk - Queues - Platform team - Service space - Jira","depth":4,"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Service-Desk - Queues - Platform team - Service space - Jira","depth":5,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"JY-20676 delete AJ reports related objects by LakyLak · Pull Request #12098 · jiminny/app","depth":4,"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"JY-20676 delete AJ reports related objects by LakyLak · Pull Request #12098 · jiminny/app","depth":5,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"JY-20915 add alias for EU by LakyLak · Pull Request #12105 · jiminny/app","depth":4,"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"JY-20915 add alias for EU by LakyLak · Pull Request #12105 · jiminny/app","depth":5,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Pipelines - jiminny/app","depth":4,"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Pipelines - jiminny/app","depth":5,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"SevenShores\\Hubspot\\Exceptions\\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {\"status\":\"error\",\"message\":\"You have reached your secondly limit.\",\"errorType\":\"RATE_LIMIT","depth":4,"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"SevenShores\\Hubspot\\Exceptions\\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {\"status\":\"error\",\"message\":\"You have reached your secondly limit.\",\"errorType\":\"RATE_LIMIT","depth":5,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Jiminny","depth":4,"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Jiminny","depth":5,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"[JY-20879] Enable users to use their new activity types - Jira","depth":4,"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"[JY-20879] Enable users to use their new activity types - Jira","depth":5,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Sentry error","depth":4,"on_screen":true,"role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":true},{"role":"AXRadioButton","text":"Illuminate\\Database\\QueryException: SQLSTATE[23000]: Integrity constraint violation: 1452 Cannot add or update a child row: a foreign key constraint fails (`jiminny`.`activities`, CONSTRAINT `activities_opportunity_id_foreign` FOREIGN KEY (`opportunity_id`","depth":4,"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Illuminate\\Database\\QueryException: SQLSTATE[23000]: Integrity constraint violation: 1452 Cannot add or update a child row: a foreign key constraint fails (`jiminny`.`activities`, CONSTRAINT `activities_opportunity_id_foreign` FOREIGN KEY (`opportunity_id`","depth":5,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"CloudWatch | eu-west-1","depth":4,"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"CloudWatch | eu-west-1","depth":5,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"JY-20842 add delete events for es by Vasil-Jiminny · Pull Request #12120 · jiminny/app","depth":4,"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"JY-20842 add delete events for es by Vasil-Jiminny · Pull Request #12120 · jiminny/app","depth":5,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"[JY-20813] Twilio number is not released when Voice role is removed from user - Jira","depth":4,"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":true},{"role":"AXStaticText","text":"[JY-20813] Twilio number is not released when Voice role is removed from user - Jira","depth":5,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Close tab","depth":5,"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"New Tab","depth":4,"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Customize sidebar","depth":6,"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Open Google Gemini (⌃X)","depth":6,"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Tabs from other devices","depth":6,"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Open history (⇧⌘H)","depth":6,"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Open bookmarks (⌘B)","depth":6,"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Skip to:","depth":9,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Sidebar","depth":10,"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Sidebar","depth":11,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Top Bar","depth":10,"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Top Bar","depth":11,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Main Content","depth":10,"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Main Content","depth":11,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Collapse sidebar [","depth":9,"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Collapse sidebar [","depth":11,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"Switch sites or apps","depth":10,"on_screen":true,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Switch sites or apps","depth":12,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Go to your Jira homepage","depth":9,"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXComboBox","text":"Search, press enter to navigate to advanced search with your text query","depth":11,"on_screen":true,"help_text":"","placeholder":"Search","role_description":"combo box","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Create","depth":10,"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Create","depth":12,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Rovo Ask Rovo","depth":12,"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Ask Rovo","depth":14,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"8 Notifications","depth":12,"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"8 Notifications","depth":14,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Help","depth":12,"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Help","depth":14,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"Settings","depth":12,"on_screen":true,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Settings","depth":14,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"lukas.kovalik@jiminny.com","depth":12,"on_screen":true,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"lukas.kovalik@jiminny.com","depth":14,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"For you","depth":12,"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"For you","depth":15,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Recent","depth":12,"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Recent","depth":15,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Starred","depth":12,"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Starred","depth":15,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Apps","depth":12,"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Apps","depth":15,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"More actions for Apps","depth":13,"on_screen":true,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"More actions for Apps","depth":15,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Spaces","depth":12,"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":true},{"role":"AXStaticText","text":"Spaces","depth":15,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Create space","depth":13,"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Create space","depth":15,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"More actions for spaces","depth":13,"on_screen":true,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"More actions for spaces","depth":15,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Recent","depth":16,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Jiminny (New)","depth":17,"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Jiminny (New)","depth":20,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Jiminny (New)","depth":18,"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":true},{"role":"AXMenuButton","text":"Create board","depth":18,"on_screen":true,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Create board","depth":20,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"More actions for Jiminny (New)","depth":18,"on_screen":true,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"More actions for Jiminny (New)","depth":20,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Platform Team","depth":19,"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Platform Team","depth":22,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"Board actions","depth":20,"on_screen":true,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Board actions","depth":22,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Capture Team","depth":19,"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Capture Team","depth":22,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"Board actions","depth":20,"on_screen":true,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Board actions","depth":22,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Enterprise Stability Issues 🤕","depth":19,"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Enterprise Stability Issues 🤕","depth":22,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"Board actions","depth":20,"on_screen":true,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Board actions","depth":22,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Processing Team","depth":19,"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Processing Team","depth":22,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"Board actions","depth":20,"on_screen":true,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Board actions","depth":22,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"SE Kanban","depth":19,"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"SE Kanban","depth":22,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"Board actions","depth":20,"on_screen":true,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Board actions","depth":22,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Service-Desk","depth":17,"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Service-Desk","depth":20,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"More actions for Service-Desk","depth":18,"on_screen":true,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"More actions for Service-Desk","depth":20,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"More spaces","depth":17,"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"More spaces","depth":20,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Filters","depth":12,"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Filters","depth":15,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"More actions for Filters","depth":13,"on_screen":true,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"More actions for Filters","depth":15,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Dashboards","depth":12,"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Dashboards","depth":15,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Create dashboard","depth":13,"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Create dashboard","depth":15,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"More actions for Dashboards","depth":13,"on_screen":true,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"More actions for Dashboards","depth":15,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Operations","depth":12,"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Operations","depth":15,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"More actions for Operations","depth":13,"on_screen":true,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"More actions for Operations","depth":15,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Confluence , (opens new window)","depth":13,"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Confluence","depth":17,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":", (opens new window)","depth":15,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Teams , (opens new window)","depth":13,"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Teams","depth":17,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":", (opens new window)","depth":15,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"open menu","depth":14,"on_screen":true,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"open menu","depth":16,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Customise sidebar","depth":12,"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Customise sidebar","depth":15,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Resize side navigation panel","depth":13,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Spaces","depth":15,"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Spaces","depth":17,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"/","depth":15,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Jiminny (New) Jiminny (New)","depth":15,"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Jiminny (New)","depth":17,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"/","depth":15,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"Epic - Change parent","depth":15,"on_screen":true,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"JY-12249","depth":15,"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"JY-12249","depth":17,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"/","depth":15,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"Bug - Change work type","depth":15,"on_screen":true,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"JY-20813","depth":15,"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"JY-20813","depth":17,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Copy link","depth":16,"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"Twilio number is not released when Voice role is removed from user- Summary, edit","depth":11,"on_screen":false,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXHeading","text":"Twilio number is not released when Voice role is removed from user","depth":11,"on_screen":true,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Twilio number is not released when Voice role is removed from user","depth":12,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"Add or create work related to this Bug","depth":12,"on_screen":true,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Add or create work related to this Bug","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"View app actions","depth":12,"on_screen":true,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"View app actions","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXHeading","text":"Collapse Key details Key details","depth":11,"on_screen":true,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXButton","text":"Collapse Key details","depth":13,"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Collapse Key details","depth":15,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Key details","depth":14,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXHeading","text":"Description","depth":12,"on_screen":true,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Description","depth":13,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"}]...
|
754415553025227305
|
5986805273405379268
|
idle
|
accessibility
|
NULL
|
[JY-20963] Fix foreign key violation when matching [JY-20963] Fix foreign key violation when matching activities to deleted Salesforce opportunities - Jira
[JY-20963] Fix foreign key violation when matching activities to deleted Salesforce opportunities - Jira
[JY-20912] Fallback mechanism for users with active SF tokens for CRM Matching - Jira
[JY-20912] Fallback mechanism for users with active SF tokens for CRM Matching - Jira
JY-20915 add alias for EU by LakyLak · Pull Request #12105 · jiminny/app
JY-20915 add alias for EU by LakyLak · Pull Request #12105 · jiminny/app
Service-Desk - Queues - Platform team - Service space - Jira
Service-Desk - Queues - Platform team - Service space - Jira
JY-20676 delete AJ reports related objects by LakyLak · Pull Request #12098 · jiminny/app
JY-20676 delete AJ reports related objects by LakyLak · Pull Request #12098 · jiminny/app
JY-20915 add alias for EU by LakyLak · Pull Request #12105 · jiminny/app
JY-20915 add alias for EU by LakyLak · Pull Request #12105 · jiminny/app
Pipelines - jiminny/app
Pipelines - jiminny/app
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {"status":"error","message":"You have reached your secondly limit.","errorType":"RATE_LIMIT
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {"status":"error","message":"You have reached your secondly limit.","errorType":"RATE_LIMIT
Jiminny
Jiminny
[JY-20879] Enable users to use their new activity types - Jira
[JY-20879] Enable users to use their new activity types - Jira
Sentry error
Illuminate\Database\QueryException: SQLSTATE[23000]: Integrity constraint violation: 1452 Cannot add or update a child row: a foreign key constraint fails (`jiminny`.`activities`, CONSTRAINT `activities_opportunity_id_foreign` FOREIGN KEY (`opportunity_id`
Illuminate\Database\QueryException: SQLSTATE[23000]: Integrity constraint violation: 1452 Cannot add or update a child row: a foreign key constraint fails (`jiminny`.`activities`, CONSTRAINT `activities_opportunity_id_foreign` FOREIGN KEY (`opportunity_id`
CloudWatch | eu-west-1
CloudWatch | eu-west-1
JY-20842 add delete events for es by Vasil-Jiminny · Pull Request #12120 · jiminny/app
JY-20842 add delete events for es by Vasil-Jiminny · Pull Request #12120 · jiminny/app
[JY-20813] Twilio number is not released when Voice role is removed from user - Jira
[JY-20813] Twilio number is not released when Voice role is removed from user - Jira
Close tab
New Tab
Customize sidebar
Open Google Gemini (⌃X)
Tabs from other devices
Open history (⇧⌘H)
Open bookmarks (⌘B)
Skip to:
Sidebar
Sidebar
Top Bar
Top Bar
Main Content
Main Content
Collapse sidebar [
Collapse sidebar [
Switch sites or apps
Switch sites or apps
Go to your Jira homepage
Search, press enter to navigate to advanced search with your text query
Create
Create
Rovo Ask Rovo
Ask Rovo
8 Notifications
8 Notifications
Help
Help
Settings
Settings
[EMAIL]
[EMAIL]
For you
For you
Recent
Recent
Starred
Starred
Apps
Apps
More actions for Apps
More actions for Apps
Spaces
Spaces
Create space
Create space
More actions for spaces
More actions for spaces
Recent
Jiminny (New)
Jiminny (New)
Jiminny (New)
Create board
Create board
More actions for Jiminny (New)
More actions for Jiminny (New)
Platform Team
Platform Team
Board actions
Board actions
Capture Team
Capture Team
Board actions
Board actions
Enterprise Stability Issues 🤕
Enterprise Stability Issues 🤕
Board actions
Board actions
Processing Team
Processing Team
Board actions
Board actions
SE Kanban
SE Kanban
Board actions
Board actions
Service-Desk
Service-Desk
More actions for Service-Desk
More actions for Service-Desk
More spaces
More spaces
Filters
Filters
More actions for Filters
More actions for Filters
Dashboards
Dashboards
Create dashboard
Create dashboard
More actions for Dashboards
More actions for Dashboards
Operations
Operations
More actions for Operations
More actions for Operations
Confluence , (opens new window)
Confluence
, (opens new window)
Teams , (opens new window)
Teams
, (opens new window)
open menu
open menu
Customise sidebar
Customise sidebar
Resize side navigation panel
Spaces
Spaces
/
Jiminny (New) Jiminny (New)
Jiminny (New)
/
Epic - Change parent
JY-12249
JY-12249
/
Bug - Change work type
JY-20813
JY-20813
Copy link
Twilio number is not released when Voice role is removed from user- Summary, edit
Twilio number is not released when Voice role is removed from user
Twilio number is not released when Voice role is removed from user
Add or create work related to this Bug
Add or create work related to this Bug
View app actions
View app actions
Collapse Key details Key details
Collapse Key details
Collapse Key details
Key details
Description
Description...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
70665
|
NULL
|
0
|
2026-05-22T10:28:11.084513+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779445691084_m2.jpg...
|
Firefox
|
[JY-20813] Twilio number is not released when Voic [JY-20813] Twilio number is not released when Voice role is removed from user - Jira — Work...
|
1
|
jiminny.atlassian.net/browse/JY-20813?atlOrigin=ey jiminny.atlassian.net/browse/JY-20813?atlOrigin=eyJpIjoiZGEwYThkZjQ5YjI4NGUyOTkyNjZiYmU0YmJmNWU0MmEiLCJwIjoiamlyYS1zbGFjay1pbnQifQ&issueKey=JY-20813&subProduct=jira-software...
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
[JY-20963] Fix foreign key violation when matching [JY-20963] Fix foreign key violation when matching activities to deleted Salesforce opportunities - Jira
[JY-20963] Fix foreign key violation when matching activities to deleted Salesforce opportunities - Jira
[JY-20912] Fallback mechanism for users with active SF tokens for CRM Matching - Jira
[JY-20912] Fallback mechanism for users with active SF tokens for CRM Matching - Jira
JY-20915 add alias for EU by LakyLak · Pull Request #12105 · jiminny/app
JY-20915 add alias for EU by LakyLak · Pull Request #12105 · jiminny/app
Service-Desk - Queues - Platform team - Service space - Jira
Service-Desk - Queues - Platform team - Service space - Jira
JY-20676 delete AJ reports related objects by LakyLak · Pull Request #12098 · jiminny/app
JY-20676 delete AJ reports related objects by LakyLak · Pull Request #12098 · jiminny/app
JY-20915 add alias for EU by LakyLak · Pull Request #12105 · jiminny/app
JY-20915 add alias for EU by LakyLak · Pull Request #12105 · jiminny/app
Pipelines - jiminny/app
Pipelines - jiminny/app
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {"status":"error","message":"You have reached your secondly limit.","errorType":"RATE_LIMIT
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {"status":"error","message":"You have reached your secondly limit.","errorType":"RATE_LIMIT
Jiminny
Jiminny
[JY-20879] Enable users to use their new activity types - Jira
[JY-20879] Enable users to use their new activity types - Jira
Sentry error
Illuminate\Database\QueryException: SQLSTATE[23000]: Integrity constraint violation: 1452 Cannot add or update a child row: a foreign key constraint fails (`jiminny`.`activities`, CONSTRAINT `activities_opportunity_id_foreign` FOREIGN KEY (`opportunity_id`
Illuminate\Database\QueryException: SQLSTATE[23000]: Integrity constraint violation: 1452 Cannot add or update a child row: a foreign key constraint fails (`jiminny`.`activities`, CONSTRAINT `activities_opportunity_id_foreign` FOREIGN KEY (`opportunity_id`
CloudWatch | eu-west-1
CloudWatch | eu-west-1
JY-20842 add delete events for es by Vasil-Jiminny · Pull Request #12120 · jiminny/app
JY-20842 add delete events for es by Vasil-Jiminny · Pull Request #12120 · jiminny/app
[JY-20813] Twilio number is not released when Voice role is removed from user - Jira
[JY-20813] Twilio number is not released when Voice role is removed from user - Jira
Close tab
New Tab
Customize sidebar
Open Google Gemini (⌃X)
Tabs from other devices
Open history (⇧⌘H)
Open bookmarks (⌘B)
Skip to:
Sidebar
Sidebar
Top Bar
Top Bar
Main Content
Main Content
Collapse sidebar [
Collapse sidebar [
Switch sites or apps
Switch sites or apps
Go to your Jira homepage
Search, press enter to navigate to advanced search with your text query
Create
Create
Rovo Ask Rovo
Ask Rovo
8 Notifications
8 Notifications
Help
Help
Settings
Settings
[EMAIL]
[EMAIL]
For you
For you
Recent
Recent...
|
[{"role":"AXRadioButton","text [{"role":"AXRadioButton","text":"[JY-20963] Fix foreign key violation when matching activities to deleted Salesforce opportunities - Jira","depth":4,"bounds":{"left":0.0,"top":0.0518755,"width":0.07962101,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"[JY-20963] Fix foreign key violation when matching activities to deleted Salesforce opportunities - Jira","depth":5,"bounds":{"left":0.013297873,"top":0.06304868,"width":0.17902261,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"[JY-20912] Fallback mechanism for users with active SF tokens for CRM Matching - Jira","depth":4,"bounds":{"left":0.0,"top":0.08459697,"width":0.07962101,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"[JY-20912] Fallback mechanism for users with active SF tokens for CRM Matching - Jira","depth":5,"bounds":{"left":0.013297873,"top":0.09577015,"width":0.15259309,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"JY-20915 add alias for EU by LakyLak · Pull Request #12105 · jiminny/app","depth":4,"bounds":{"left":0.0,"top":0.11731844,"width":0.07962101,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"JY-20915 add alias for EU by LakyLak · Pull Request #12105 · jiminny/app","depth":5,"bounds":{"left":0.013297873,"top":0.12849163,"width":0.12699468,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Service-Desk - Queues - Platform team - Service space - Jira","depth":4,"bounds":{"left":0.0,"top":0.15003991,"width":0.07962101,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Service-Desk - Queues - Platform team - Service space - Jira","depth":5,"bounds":{"left":0.013297873,"top":0.16121309,"width":0.10721409,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"JY-20676 delete AJ reports related objects by LakyLak · Pull Request #12098 · jiminny/app","depth":4,"bounds":{"left":0.0,"top":0.18276137,"width":0.07962101,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"JY-20676 delete AJ reports related objects by LakyLak · Pull Request #12098 · jiminny/app","depth":5,"bounds":{"left":0.013297873,"top":0.19393456,"width":0.15791224,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"JY-20915 add alias for EU by LakyLak · Pull Request #12105 · jiminny/app","depth":4,"bounds":{"left":0.0,"top":0.21548285,"width":0.07962101,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"JY-20915 add alias for EU by LakyLak · Pull Request #12105 · jiminny/app","depth":5,"bounds":{"left":0.013297873,"top":0.22665602,"width":0.12699468,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Pipelines - jiminny/app","depth":4,"bounds":{"left":0.0,"top":0.2482043,"width":0.07962101,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Pipelines - jiminny/app","depth":5,"bounds":{"left":0.013297873,"top":0.25937748,"width":0.039228722,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"SevenShores\\Hubspot\\Exceptions\\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {\"status\":\"error\",\"message\":\"You have reached your secondly limit.\",\"errorType\":\"RATE_LIMIT","depth":4,"bounds":{"left":0.0,"top":0.28092578,"width":0.07962101,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"SevenShores\\Hubspot\\Exceptions\\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {\"status\":\"error\",\"message\":\"You have reached your secondly limit.\",\"errorType\":\"RATE_LIMIT","depth":5,"bounds":{"left":0.013297873,"top":0.29209897,"width":0.4644282,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Jiminny","depth":4,"bounds":{"left":0.0,"top":0.31364724,"width":0.07962101,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Jiminny","depth":5,"bounds":{"left":0.013297873,"top":0.32482043,"width":0.013131649,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"[JY-20879] Enable users to use their new activity types - Jira","depth":4,"bounds":{"left":0.0,"top":0.3463687,"width":0.07962101,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"[JY-20879] Enable users to use their new activity types - Jira","depth":5,"bounds":{"left":0.013297873,"top":0.3575419,"width":0.106715426,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Sentry error","depth":4,"bounds":{"left":0.0028257978,"top":0.38427773,"width":0.024601065,"height":0.01915403},"on_screen":true,"role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":true},{"role":"AXRadioButton","text":"Illuminate\\Database\\QueryException: SQLSTATE[23000]: Integrity constraint violation: 1452 Cannot add or update a child row: a foreign key constraint fails (`jiminny`.`activities`, CONSTRAINT `activities_opportunity_id_foreign` FOREIGN KEY (`opportunity_id`","depth":4,"bounds":{"left":0.0028257978,"top":0.40782124,"width":0.07679521,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Illuminate\\Database\\QueryException: SQLSTATE[23000]: Integrity constraint violation: 1452 Cannot add or update a child row: a foreign key constraint fails (`jiminny`.`activities`, CONSTRAINT `activities_opportunity_id_foreign` FOREIGN KEY (`opportunity_id`","depth":5,"bounds":{"left":0.015957447,"top":0.41899443,"width":0.45362368,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"CloudWatch | eu-west-1","depth":4,"bounds":{"left":0.0028257978,"top":0.4405427,"width":0.07679521,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"CloudWatch | eu-west-1","depth":5,"bounds":{"left":0.015957447,"top":0.4517159,"width":0.041722074,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"JY-20842 add delete events for es by Vasil-Jiminny · Pull Request #12120 · jiminny/app","depth":4,"bounds":{"left":0.0,"top":0.47326416,"width":0.07962101,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"JY-20842 add delete events for es by Vasil-Jiminny · Pull Request #12120 · jiminny/app","depth":5,"bounds":{"left":0.013297873,"top":0.48443735,"width":0.15109707,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"[JY-20813] Twilio number is not released when Voice role is removed from user - Jira","depth":4,"bounds":{"left":0.0,"top":0.5059856,"width":0.07962101,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":true},{"role":"AXStaticText","text":"[JY-20813] Twilio number is not released when Voice role is removed from user - Jira","depth":5,"bounds":{"left":0.013297873,"top":0.5171588,"width":0.14793883,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Close tab","depth":5,"bounds":{"left":0.06732048,"top":0.5131684,"width":0.007978723,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"New Tab","depth":4,"bounds":{"left":0.0028257978,"top":0.5403033,"width":0.07413564,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Customize sidebar","depth":6,"bounds":{"left":0.0028257978,"top":0.97007185,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Open Google Gemini (⌃X)","depth":6,"bounds":{"left":0.013796543,"top":0.97007185,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Tabs from other devices","depth":6,"bounds":{"left":0.024933511,"top":0.97007185,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Open history (⇧⌘H)","depth":6,"bounds":{"left":0.036070477,"top":0.97007185,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Open bookmarks (⌘B)","depth":6,"bounds":{"left":0.04720745,"top":0.97007185,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Skip to:","depth":9,"bounds":{"left":0.090259306,"top":0.07861133,"width":0.016954787,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Sidebar","depth":10,"bounds":{"left":0.090259306,"top":0.097765364,"width":0.016954787,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Sidebar","depth":11,"bounds":{"left":0.090259306,"top":0.097765364,"width":0.016954787,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Top Bar","depth":10,"bounds":{"left":0.090259306,"top":0.11691939,"width":0.016954787,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Top Bar","depth":11,"bounds":{"left":0.090259306,"top":0.11691939,"width":0.016954787,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Main Content","depth":10,"bounds":{"left":0.090259306,"top":0.13607343,"width":0.029421542,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Main Content","depth":11,"bounds":{"left":0.090259306,"top":0.13607343,"width":0.029421542,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Collapse sidebar [","depth":9,"bounds":{"left":0.08361037,"top":0.057861134,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Collapse sidebar [","depth":11,"bounds":{"left":0.0887633,"top":0.06344773,"width":0.039727394,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"Switch sites or apps","depth":10,"bounds":{"left":0.095578454,"top":0.057861134,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Switch sites or apps","depth":12,"bounds":{"left":0.10073138,"top":0.06344773,"width":0.044215426,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Go to your Jira homepage","depth":9,"bounds":{"left":0.10887633,"top":0.057861134,"width":0.029421542,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXComboBox","text":"Search, press enter to navigate to advanced search with your text query","depth":11,"bounds":{"left":0.40475398,"top":0.06264964,"width":0.24268617,"height":0.015961692},"on_screen":true,"help_text":"","placeholder":"Search","role_description":"combo box","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Create","depth":10,"bounds":{"left":0.65575135,"top":0.057861134,"width":0.030086435,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Create","depth":12,"bounds":{"left":0.66705453,"top":0.06384677,"width":0.014793883,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Rovo Ask Rovo","depth":12,"bounds":{"left":0.91223407,"top":0.057861134,"width":0.035904255,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Ask Rovo","depth":14,"bounds":{"left":0.92353725,"top":0.06384677,"width":0.020611702,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"8 Notifications","depth":12,"bounds":{"left":0.9494681,"top":0.057861134,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"8 Notifications","depth":14,"bounds":{"left":0.954621,"top":0.06344773,"width":0.031914894,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Help","depth":12,"bounds":{"left":0.96143615,"top":0.057861134,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Help","depth":14,"bounds":{"left":0.9665891,"top":0.06344773,"width":0.010139627,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"Settings","depth":12,"bounds":{"left":0.9734042,"top":0.057861134,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Settings","depth":14,"bounds":{"left":0.97855717,"top":0.06344773,"width":0.017952127,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"lukas.kovalik@jiminny.com","depth":12,"bounds":{"left":0.98537236,"top":0.057861134,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"lukas.kovalik@jiminny.com","depth":14,"bounds":{"left":0.99052525,"top":0.06344773,"width":0.009474754,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"For you","depth":12,"bounds":{"left":0.08361037,"top":0.09976058,"width":0.071476065,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"For you","depth":15,"bounds":{"left":0.09424867,"top":0.10574621,"width":0.01662234,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Recent","depth":12,"bounds":{"left":0.08361037,"top":0.12529927,"width":0.071476065,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Recent","depth":15,"bounds":{"left":0.09424867,"top":0.13128492,"width":0.015458777,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"}]...
|
-6828764309559561056
|
-4445765819654516096
|
idle
|
accessibility
|
NULL
|
[JY-20963] Fix foreign key violation when matching [JY-20963] Fix foreign key violation when matching activities to deleted Salesforce opportunities - Jira
[JY-20963] Fix foreign key violation when matching activities to deleted Salesforce opportunities - Jira
[JY-20912] Fallback mechanism for users with active SF tokens for CRM Matching - Jira
[JY-20912] Fallback mechanism for users with active SF tokens for CRM Matching - Jira
JY-20915 add alias for EU by LakyLak · Pull Request #12105 · jiminny/app
JY-20915 add alias for EU by LakyLak · Pull Request #12105 · jiminny/app
Service-Desk - Queues - Platform team - Service space - Jira
Service-Desk - Queues - Platform team - Service space - Jira
JY-20676 delete AJ reports related objects by LakyLak · Pull Request #12098 · jiminny/app
JY-20676 delete AJ reports related objects by LakyLak · Pull Request #12098 · jiminny/app
JY-20915 add alias for EU by LakyLak · Pull Request #12105 · jiminny/app
JY-20915 add alias for EU by LakyLak · Pull Request #12105 · jiminny/app
Pipelines - jiminny/app
Pipelines - jiminny/app
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {"status":"error","message":"You have reached your secondly limit.","errorType":"RATE_LIMIT
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {"status":"error","message":"You have reached your secondly limit.","errorType":"RATE_LIMIT
Jiminny
Jiminny
[JY-20879] Enable users to use their new activity types - Jira
[JY-20879] Enable users to use their new activity types - Jira
Sentry error
Illuminate\Database\QueryException: SQLSTATE[23000]: Integrity constraint violation: 1452 Cannot add or update a child row: a foreign key constraint fails (`jiminny`.`activities`, CONSTRAINT `activities_opportunity_id_foreign` FOREIGN KEY (`opportunity_id`
Illuminate\Database\QueryException: SQLSTATE[23000]: Integrity constraint violation: 1452 Cannot add or update a child row: a foreign key constraint fails (`jiminny`.`activities`, CONSTRAINT `activities_opportunity_id_foreign` FOREIGN KEY (`opportunity_id`
CloudWatch | eu-west-1
CloudWatch | eu-west-1
JY-20842 add delete events for es by Vasil-Jiminny · Pull Request #12120 · jiminny/app
JY-20842 add delete events for es by Vasil-Jiminny · Pull Request #12120 · jiminny/app
[JY-20813] Twilio number is not released when Voice role is removed from user - Jira
[JY-20813] Twilio number is not released when Voice role is removed from user - Jira
Close tab
New Tab
Customize sidebar
Open Google Gemini (⌃X)
Tabs from other devices
Open history (⇧⌘H)
Open bookmarks (⌘B)
Skip to:
Sidebar
Sidebar
Top Bar
Top Bar
Main Content
Main Content
Collapse sidebar [
Collapse sidebar [
Switch sites or apps
Switch sites or apps
Go to your Jira homepage
Search, press enter to navigate to advanced search with your text query
Create
Create
Rovo Ask Rovo
Ask Rovo
8 Notifications
8 Notifications
Help
Help
Settings
Settings
[EMAIL]
[EMAIL]
For you
For you
Recent
Recent...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
70664
|
NULL
|
0
|
2026-05-22T10:28:06.128482+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779445686128_m1.jpg...
|
Firefox
|
[JY-20813] Twilio number is not released when Voic [JY-20813] Twilio number is not released when Voice role is removed from user - Jira — Work...
|
1
|
jiminny.atlassian.net/browse/JY-20813?atlOrigin=ey jiminny.atlassian.net/browse/JY-20813?atlOrigin=eyJpIjoiZGEwYThkZjQ5YjI4NGUyOTkyNjZiYmU0YmJmNWU0MmEiLCJwIjoiamlyYS1zbGFjay1pbnQifQ&issueKey=JY-20813&subProduct=jira-software...
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
[JY-20963] Fix foreign key violation when matching [JY-20963] Fix foreign key violation when matching activities to deleted Salesforce opportunities - Jira
[JY-20963] Fix foreign key violation when matching activities to deleted Salesforce opportunities - Jira
[JY-20912] Fallback mechanism for users with active SF tokens for CRM Matching - Jira
[JY-20912] Fallback mechanism for users with active SF tokens for CRM Matching - Jira
JY-20915 add alias for EU by LakyLak · Pull Request #12105 · jiminny/app
JY-20915 add alias for EU by LakyLak · Pull Request #12105 · jiminny/app
Service-Desk - Queues - Platform team - Service space - Jira
Service-Desk - Queues - Platform team - Service space - Jira
JY-20676 delete AJ reports related objects by LakyLak · Pull Request #12098 · jiminny/app
JY-20676 delete AJ reports related objects by LakyLak · Pull Request #12098 · jiminny/app
JY-20915 add alias for EU by LakyLak · Pull Request #12105 · jiminny/app
JY-20915 add alias for EU by LakyLak · Pull Request #12105 · jiminny/app
Pipelines - jiminny/app
Pipelines - jiminny/app
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {"status":"error","message":"You have reached your secondly limit.","errorType":"RATE_LIMIT
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {"status":"error","message":"You have reached your secondly limit.","errorType":"RATE_LIMIT
Jiminny
Jiminny
[JY-20879] Enable users to use their new activity types - Jira
[JY-20879] Enable users to use their new activity types - Jira
Sentry error
Illuminate\Database\QueryException: SQLSTATE[23000]: Integrity constraint violation: 1452 Cannot add or update a child row: a foreign key constraint fails (`jiminny`.`activities`, CONSTRAINT `activities_opportunity_id_foreign` FOREIGN KEY (`opportunity_id`
Illuminate\Database\QueryException: SQLSTATE[23000]: Integrity constraint violation: 1452 Cannot add or update a child row: a foreign key constraint fails (`jiminny`.`activities`, CONSTRAINT `activities_opportunity_id_foreign` FOREIGN KEY (`opportunity_id`
CloudWatch | eu-west-1
CloudWatch | eu-west-1
JY-20842 add delete events for es by Vasil-Jiminny · Pull Request #12120 · jiminny/app
JY-20842 add delete events for es by Vasil-Jiminny · Pull Request #12120 · jiminny/app
[JY-20813] Twilio number is not released when Voice role is removed from user - Jira
[JY-20813] Twilio number is not released when Voice role is removed from user - Jira
Close tab
New Tab
Customize sidebar
Open Google Gemini (⌃X)
Tabs from other devices
Open history (⇧⌘H)
Open bookmarks (⌘B)
Skip to:
Sidebar
Sidebar
Top Bar
Top Bar
Main Content
Main Content
Collapse sidebar [
Collapse sidebar [
Switch sites or apps
Switch sites or apps
Go to your Jira homepage
Search, press enter to navigate to advanced search with your text query
Create
Create
Rovo Ask Rovo
Ask Rovo
8 Notifications
8 Notifications
Help
Help
Settings
Settings
[EMAIL]
[EMAIL]
For you
For you
Recent
Recent
Starred
Starred...
|
[{"role":"AXRadioButton","text [{"role":"AXRadioButton","text":"[JY-20963] Fix foreign key violation when matching activities to deleted Salesforce opportunities - Jira","depth":4,"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"[JY-20963] Fix foreign key violation when matching activities to deleted Salesforce opportunities - Jira","depth":5,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"[JY-20912] Fallback mechanism for users with active SF tokens for CRM Matching - Jira","depth":4,"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"[JY-20912] Fallback mechanism for users with active SF tokens for CRM Matching - Jira","depth":5,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"JY-20915 add alias for EU by LakyLak · Pull Request #12105 · jiminny/app","depth":4,"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"JY-20915 add alias for EU by LakyLak · Pull Request #12105 · jiminny/app","depth":5,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Service-Desk - Queues - Platform team - Service space - Jira","depth":4,"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Service-Desk - Queues - Platform team - Service space - Jira","depth":5,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"JY-20676 delete AJ reports related objects by LakyLak · Pull Request #12098 · jiminny/app","depth":4,"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"JY-20676 delete AJ reports related objects by LakyLak · Pull Request #12098 · jiminny/app","depth":5,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"JY-20915 add alias for EU by LakyLak · Pull Request #12105 · jiminny/app","depth":4,"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"JY-20915 add alias for EU by LakyLak · Pull Request #12105 · jiminny/app","depth":5,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Pipelines - jiminny/app","depth":4,"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Pipelines - jiminny/app","depth":5,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"SevenShores\\Hubspot\\Exceptions\\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {\"status\":\"error\",\"message\":\"You have reached your secondly limit.\",\"errorType\":\"RATE_LIMIT","depth":4,"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"SevenShores\\Hubspot\\Exceptions\\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {\"status\":\"error\",\"message\":\"You have reached your secondly limit.\",\"errorType\":\"RATE_LIMIT","depth":5,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Jiminny","depth":4,"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Jiminny","depth":5,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"[JY-20879] Enable users to use their new activity types - Jira","depth":4,"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"[JY-20879] Enable users to use their new activity types - Jira","depth":5,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Sentry error","depth":4,"on_screen":true,"role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":true},{"role":"AXRadioButton","text":"Illuminate\\Database\\QueryException: SQLSTATE[23000]: Integrity constraint violation: 1452 Cannot add or update a child row: a foreign key constraint fails (`jiminny`.`activities`, CONSTRAINT `activities_opportunity_id_foreign` FOREIGN KEY (`opportunity_id`","depth":4,"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Illuminate\\Database\\QueryException: SQLSTATE[23000]: Integrity constraint violation: 1452 Cannot add or update a child row: a foreign key constraint fails (`jiminny`.`activities`, CONSTRAINT `activities_opportunity_id_foreign` FOREIGN KEY (`opportunity_id`","depth":5,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"CloudWatch | eu-west-1","depth":4,"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"CloudWatch | eu-west-1","depth":5,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"JY-20842 add delete events for es by Vasil-Jiminny · Pull Request #12120 · jiminny/app","depth":4,"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"JY-20842 add delete events for es by Vasil-Jiminny · Pull Request #12120 · jiminny/app","depth":5,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"[JY-20813] Twilio number is not released when Voice role is removed from user - Jira","depth":4,"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":true},{"role":"AXStaticText","text":"[JY-20813] Twilio number is not released when Voice role is removed from user - Jira","depth":5,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Close tab","depth":5,"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"New Tab","depth":4,"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Customize sidebar","depth":6,"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Open Google Gemini (⌃X)","depth":6,"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Tabs from other devices","depth":6,"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Open history (⇧⌘H)","depth":6,"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Open bookmarks (⌘B)","depth":6,"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Skip to:","depth":9,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Sidebar","depth":10,"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Sidebar","depth":11,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Top Bar","depth":10,"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Top Bar","depth":11,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Main Content","depth":10,"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Main Content","depth":11,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Collapse sidebar [","depth":9,"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Collapse sidebar [","depth":11,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"Switch sites or apps","depth":10,"on_screen":true,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Switch sites or apps","depth":12,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Go to your Jira homepage","depth":9,"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXComboBox","text":"Search, press enter to navigate to advanced search with your text query","depth":11,"on_screen":true,"help_text":"","placeholder":"Search","role_description":"combo box","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Create","depth":10,"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Create","depth":12,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Rovo Ask Rovo","depth":12,"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Ask Rovo","depth":14,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"8 Notifications","depth":12,"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"8 Notifications","depth":14,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Help","depth":12,"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Help","depth":14,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"Settings","depth":12,"on_screen":true,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Settings","depth":14,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"lukas.kovalik@jiminny.com","depth":12,"on_screen":true,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"lukas.kovalik@jiminny.com","depth":14,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"For you","depth":12,"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"For you","depth":15,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Recent","depth":12,"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Recent","depth":15,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Starred","depth":12,"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Starred","depth":15,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"}]...
|
7371323744550474385
|
-4445765819654516096
|
idle
|
accessibility
|
NULL
|
[JY-20963] Fix foreign key violation when matching [JY-20963] Fix foreign key violation when matching activities to deleted Salesforce opportunities - Jira
[JY-20963] Fix foreign key violation when matching activities to deleted Salesforce opportunities - Jira
[JY-20912] Fallback mechanism for users with active SF tokens for CRM Matching - Jira
[JY-20912] Fallback mechanism for users with active SF tokens for CRM Matching - Jira
JY-20915 add alias for EU by LakyLak · Pull Request #12105 · jiminny/app
JY-20915 add alias for EU by LakyLak · Pull Request #12105 · jiminny/app
Service-Desk - Queues - Platform team - Service space - Jira
Service-Desk - Queues - Platform team - Service space - Jira
JY-20676 delete AJ reports related objects by LakyLak · Pull Request #12098 · jiminny/app
JY-20676 delete AJ reports related objects by LakyLak · Pull Request #12098 · jiminny/app
JY-20915 add alias for EU by LakyLak · Pull Request #12105 · jiminny/app
JY-20915 add alias for EU by LakyLak · Pull Request #12105 · jiminny/app
Pipelines - jiminny/app
Pipelines - jiminny/app
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {"status":"error","message":"You have reached your secondly limit.","errorType":"RATE_LIMIT
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {"status":"error","message":"You have reached your secondly limit.","errorType":"RATE_LIMIT
Jiminny
Jiminny
[JY-20879] Enable users to use their new activity types - Jira
[JY-20879] Enable users to use their new activity types - Jira
Sentry error
Illuminate\Database\QueryException: SQLSTATE[23000]: Integrity constraint violation: 1452 Cannot add or update a child row: a foreign key constraint fails (`jiminny`.`activities`, CONSTRAINT `activities_opportunity_id_foreign` FOREIGN KEY (`opportunity_id`
Illuminate\Database\QueryException: SQLSTATE[23000]: Integrity constraint violation: 1452 Cannot add or update a child row: a foreign key constraint fails (`jiminny`.`activities`, CONSTRAINT `activities_opportunity_id_foreign` FOREIGN KEY (`opportunity_id`
CloudWatch | eu-west-1
CloudWatch | eu-west-1
JY-20842 add delete events for es by Vasil-Jiminny · Pull Request #12120 · jiminny/app
JY-20842 add delete events for es by Vasil-Jiminny · Pull Request #12120 · jiminny/app
[JY-20813] Twilio number is not released when Voice role is removed from user - Jira
[JY-20813] Twilio number is not released when Voice role is removed from user - Jira
Close tab
New Tab
Customize sidebar
Open Google Gemini (⌃X)
Tabs from other devices
Open history (⇧⌘H)
Open bookmarks (⌘B)
Skip to:
Sidebar
Sidebar
Top Bar
Top Bar
Main Content
Main Content
Collapse sidebar [
Collapse sidebar [
Switch sites or apps
Switch sites or apps
Go to your Jira homepage
Search, press enter to navigate to advanced search with your text query
Create
Create
Rovo Ask Rovo
Ask Rovo
8 Notifications
8 Notifications
Help
Help
Settings
Settings
[EMAIL]
[EMAIL]
For you
For you
Recent
Recent
Starred
Starred...
|
70660
|
NULL
|
NULL
|
NULL
|
|
70663
|
2537
|
23
|
2026-05-22T10:27:40.514224+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779445660514_m2.jpg...
|
Firefox
|
Jira — Work
|
1
|
jiminny.atlassian.net/browse/JY-20813?atlOrigin=ey jiminny.atlassian.net/browse/JY-20813?atlOrigin=eyJpIjoiZGEwYThkZjQ5YjI4NGUyOTkyNjZiYmU0YmJmNWU0MmEiLCJwIjoiamlyYS1zbGFjay1pbnQifQ&issueKey=JY-20813&subProduct=jira-software...
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
[JY-20963] Fix foreign key violation when matching [JY-20963] Fix foreign key violation when matching activities to deleted Salesforce opportunities - Jira
[JY-20963] Fix foreign key violation when matching activities to deleted Salesforce opportunities - Jira
[JY-20912] Fallback mechanism for users with active SF tokens for CRM Matching - Jira
[JY-20912] Fallback mechanism for users with active SF tokens for CRM Matching - Jira
JY-20915 add alias for EU by LakyLak · Pull Request #12105 · jiminny/app
JY-20915 add alias for EU by LakyLak · Pull Request #12105 · jiminny/app
Service-Desk - Queues - Platform team - Service space - Jira
Service-Desk - Queues - Platform team - Service space - Jira
JY-20676 delete AJ reports related objects by LakyLak · Pull Request #12098 · jiminny/app
JY-20676 delete AJ reports related objects by LakyLak · Pull Request #12098 · jiminny/app
JY-20915 add alias for EU by LakyLak · Pull Request #12105 · jiminny/app
JY-20915 add alias for EU by LakyLak · Pull Request #12105 · jiminny/app
Pipelines - jiminny/app
Pipelines - jiminny/app
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {"status":"error","message":"You have reached your secondly limit.","errorType":"RATE_LIMIT
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {"status":"error","message":"You have reached your secondly limit.","errorType":"RATE_LIMIT
Jiminny
Jiminny
[JY-20879] Enable users to use their new activity types - Jira
[JY-20879] Enable users to use their new activity types - Jira
Sentry error
Illuminate\Database\QueryException: SQLSTATE[23000]: Integrity constraint violation: 1452 Cannot add or update a child row: a foreign key constraint fails (`jiminny`.`activities`, CONSTRAINT `activities_opportunity_id_foreign` FOREIGN KEY (`opportunity_id`
Illuminate\Database\QueryException: SQLSTATE[23000]: Integrity constraint violation: 1452 Cannot add or update a child row: a foreign key constraint fails (`jiminny`.`activities`, CONSTRAINT `activities_opportunity_id_foreign` FOREIGN KEY (`opportunity_id`
CloudWatch | eu-west-1
CloudWatch | eu-west-1
JY-20842 add delete events for es by Vasil-Jiminny · Pull Request #12120 · jiminny/app
JY-20842 add delete events for es by Vasil-Jiminny · Pull Request #12120 · jiminny/app
Jira
Jira
Close tab
New Tab
Customize sidebar
Open Google Gemini (⌃X)
Tabs from other devices
Open history (⇧⌘H)
Open bookmarks (⌘B)
Skip to:
Sidebar
Sidebar
Top Bar
Top Bar
Main Content
Main Content
Collapse sidebar [
Collapse sidebar [
Switch sites or apps
Switch sites or apps
Go to your Jira homepage
Search, press enter to navigate to advanced search with your text query
Create
Create
Rovo Ask Rovo
Ask Rovo
8 Notifications
8 Notifications
Help
Help
Settings
Settings
[EMAIL]
[EMAIL]
For you
For you
Recent
Recent
Starred
Starred
Apps
Apps
More actions for Apps
More actions for Apps
Spaces
Spaces
Create space
Create space
More actions for spaces
More actions for spaces
Recent
Jiminny (New)
Jiminny (New)
Jiminny (New)
Create board
Create board
More actions for Jiminny (New)
More actions for Jiminny (New)
Platform Team
Platform Team
Board actions
Board actions
Capture Team
Capture Team
Board actions
Board actions
Enterprise Stability Issues 🤕
Enterprise Stability Issues 🤕
Board actions
Board actions
Processing Team
Processing Team
Board actions
Board actions
SE Kanban
SE Kanban
Board actions
Board actions
Service-Desk
Service-Desk
More actions for Service-Desk
More actions for Service-Desk
More spaces
More spaces
Filters
Filters
More actions for Filters
More actions for Filters
Dashboards
Dashboards
Create dashboard
Create dashboard
More actions for Dashboards
More actions for Dashboards
Operations
Operations
More actions for Operations
More actions for Operations
Assets , (opens new window)
Assets
, (opens new window)
Teams , (opens new window)
Teams
, (opens new window)
open menu
open menu
Customise sidebar
Customise sidebar
Resize side navigation panel
Spaces
Spaces
/
Jiminny (New) Jiminny (New)
Jiminny (New)
/
Epic - Change parent
JY-12249
JY-12249
/
Bug - Change work type
JY-20813
JY-20813
Copy link
Twilio number is not released when Voice role is removed from user- Summary, edit
Twilio number is not released when Voice role is removed from user
Twilio number is not released when Voice role is removed from user
Add or create work related to this Bug
Add or create work related to this Bug
View app actions
View app actions
Collapse Key details Key details
Collapse Key details
Collapse Key details
Key details
Description
Description
Add Description, edit
Precondition:
User “A” has
Recorder & Voice
role and has an assigned Twilio softphone number....
|
[{"role":"AXRadioButton","text [{"role":"AXRadioButton","text":"[JY-20963] Fix foreign key violation when matching activities to deleted Salesforce opportunities - Jira","depth":4,"bounds":{"left":0.0,"top":0.0518755,"width":0.07962101,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"[JY-20963] Fix foreign key violation when matching activities to deleted Salesforce opportunities - Jira","depth":5,"bounds":{"left":0.013297873,"top":0.06304868,"width":0.17902261,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"[JY-20912] Fallback mechanism for users with active SF tokens for CRM Matching - Jira","depth":4,"bounds":{"left":0.0,"top":0.08459697,"width":0.07962101,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"[JY-20912] Fallback mechanism for users with active SF tokens for CRM Matching - Jira","depth":5,"bounds":{"left":0.013297873,"top":0.09577015,"width":0.15259309,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"JY-20915 add alias for EU by LakyLak · Pull Request #12105 · jiminny/app","depth":4,"bounds":{"left":0.0,"top":0.11731844,"width":0.07962101,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"JY-20915 add alias for EU by LakyLak · Pull Request #12105 · jiminny/app","depth":5,"bounds":{"left":0.013297873,"top":0.12849163,"width":0.12699468,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Service-Desk - Queues - Platform team - Service space - Jira","depth":4,"bounds":{"left":0.0,"top":0.15003991,"width":0.07962101,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Service-Desk - Queues - Platform team - Service space - Jira","depth":5,"bounds":{"left":0.013297873,"top":0.16121309,"width":0.10721409,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"JY-20676 delete AJ reports related objects by LakyLak · Pull Request #12098 · jiminny/app","depth":4,"bounds":{"left":0.0,"top":0.18276137,"width":0.07962101,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"JY-20676 delete AJ reports related objects by LakyLak · Pull Request #12098 · jiminny/app","depth":5,"bounds":{"left":0.013297873,"top":0.19393456,"width":0.15791224,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"JY-20915 add alias for EU by LakyLak · Pull Request #12105 · jiminny/app","depth":4,"bounds":{"left":0.0,"top":0.21548285,"width":0.07962101,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"JY-20915 add alias for EU by LakyLak · Pull Request #12105 · jiminny/app","depth":5,"bounds":{"left":0.013297873,"top":0.22665602,"width":0.12699468,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Pipelines - jiminny/app","depth":4,"bounds":{"left":0.0,"top":0.2482043,"width":0.07962101,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Pipelines - jiminny/app","depth":5,"bounds":{"left":0.013297873,"top":0.25937748,"width":0.039228722,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"SevenShores\\Hubspot\\Exceptions\\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {\"status\":\"error\",\"message\":\"You have reached your secondly limit.\",\"errorType\":\"RATE_LIMIT","depth":4,"bounds":{"left":0.0,"top":0.28092578,"width":0.07962101,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"SevenShores\\Hubspot\\Exceptions\\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {\"status\":\"error\",\"message\":\"You have reached your secondly limit.\",\"errorType\":\"RATE_LIMIT","depth":5,"bounds":{"left":0.013297873,"top":0.29209897,"width":0.4644282,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Jiminny","depth":4,"bounds":{"left":0.0,"top":0.31364724,"width":0.07962101,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Jiminny","depth":5,"bounds":{"left":0.013297873,"top":0.32482043,"width":0.013131649,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"[JY-20879] Enable users to use their new activity types - Jira","depth":4,"bounds":{"left":0.0,"top":0.3463687,"width":0.07962101,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"[JY-20879] Enable users to use their new activity types - Jira","depth":5,"bounds":{"left":0.013297873,"top":0.3575419,"width":0.106715426,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Sentry error","depth":4,"bounds":{"left":0.0028257978,"top":0.38427773,"width":0.024601065,"height":0.01915403},"on_screen":true,"role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":true},{"role":"AXRadioButton","text":"Illuminate\\Database\\QueryException: SQLSTATE[23000]: Integrity constraint violation: 1452 Cannot add or update a child row: a foreign key constraint fails (`jiminny`.`activities`, CONSTRAINT `activities_opportunity_id_foreign` FOREIGN KEY (`opportunity_id`","depth":4,"bounds":{"left":0.0028257978,"top":0.40782124,"width":0.07679521,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Illuminate\\Database\\QueryException: SQLSTATE[23000]: Integrity constraint violation: 1452 Cannot add or update a child row: a foreign key constraint fails (`jiminny`.`activities`, CONSTRAINT `activities_opportunity_id_foreign` FOREIGN KEY (`opportunity_id`","depth":5,"bounds":{"left":0.015957447,"top":0.41899443,"width":0.45362368,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"CloudWatch | eu-west-1","depth":4,"bounds":{"left":0.0028257978,"top":0.4405427,"width":0.07679521,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"CloudWatch | eu-west-1","depth":5,"bounds":{"left":0.015957447,"top":0.4517159,"width":0.041722074,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"JY-20842 add delete events for es by Vasil-Jiminny · Pull Request #12120 · jiminny/app","depth":4,"bounds":{"left":0.0,"top":0.47326416,"width":0.07962101,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"JY-20842 add delete events for es by Vasil-Jiminny · Pull Request #12120 · jiminny/app","depth":5,"bounds":{"left":0.013297873,"top":0.48443735,"width":0.15109707,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Jira","depth":4,"bounds":{"left":0.0,"top":0.5059856,"width":0.07962101,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":true},{"role":"AXStaticText","text":"Jira","depth":5,"bounds":{"left":0.013297873,"top":0.5171588,"width":0.0063164895,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Close tab","depth":5,"bounds":{"left":0.06732048,"top":0.5131684,"width":0.007978723,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"New Tab","depth":4,"bounds":{"left":0.0028257978,"top":0.5403033,"width":0.07413564,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Customize sidebar","depth":6,"bounds":{"left":0.0028257978,"top":0.97007185,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Open Google Gemini (⌃X)","depth":6,"bounds":{"left":0.013796543,"top":0.97007185,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Tabs from other devices","depth":6,"bounds":{"left":0.024933511,"top":0.97007185,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Open history (⇧⌘H)","depth":6,"bounds":{"left":0.036070477,"top":0.97007185,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Open bookmarks (⌘B)","depth":6,"bounds":{"left":0.04720745,"top":0.97007185,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Skip to:","depth":9,"bounds":{"left":0.090259306,"top":0.07861133,"width":0.016954787,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Sidebar","depth":10,"bounds":{"left":0.090259306,"top":0.097765364,"width":0.016954787,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Sidebar","depth":11,"bounds":{"left":0.090259306,"top":0.097765364,"width":0.016954787,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Top Bar","depth":10,"bounds":{"left":0.090259306,"top":0.11691939,"width":0.016954787,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Top Bar","depth":11,"bounds":{"left":0.090259306,"top":0.11691939,"width":0.016954787,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Main Content","depth":10,"bounds":{"left":0.090259306,"top":0.13607343,"width":0.029421542,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Main Content","depth":11,"bounds":{"left":0.090259306,"top":0.13607343,"width":0.029421542,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Collapse sidebar [","depth":9,"bounds":{"left":0.08361037,"top":0.057861134,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Collapse sidebar [","depth":11,"bounds":{"left":0.0887633,"top":0.06344773,"width":0.039727394,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"Switch sites or apps","depth":10,"bounds":{"left":0.095578454,"top":0.057861134,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Switch sites or apps","depth":12,"bounds":{"left":0.10073138,"top":0.06344773,"width":0.044215426,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Go to your Jira homepage","depth":9,"bounds":{"left":0.10887633,"top":0.057861134,"width":0.029421542,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXComboBox","text":"Search, press enter to navigate to advanced search with your text query","depth":11,"bounds":{"left":0.40475398,"top":0.06264964,"width":0.24268617,"height":0.015961692},"on_screen":true,"help_text":"","placeholder":"Search","role_description":"combo box","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Create","depth":10,"bounds":{"left":0.65575135,"top":0.057861134,"width":0.030086435,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Create","depth":12,"bounds":{"left":0.66705453,"top":0.06384677,"width":0.014793883,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Rovo Ask Rovo","depth":12,"bounds":{"left":0.91223407,"top":0.057861134,"width":0.035904255,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Ask Rovo","depth":14,"bounds":{"left":0.92353725,"top":0.06384677,"width":0.020611702,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"8 Notifications","depth":12,"bounds":{"left":0.9494681,"top":0.057861134,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"8 Notifications","depth":14,"bounds":{"left":0.954621,"top":0.06344773,"width":0.031914894,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Help","depth":12,"bounds":{"left":0.96143615,"top":0.057861134,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Help","depth":14,"bounds":{"left":0.9665891,"top":0.06344773,"width":0.010139627,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"Settings","depth":12,"bounds":{"left":0.9734042,"top":0.057861134,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Settings","depth":14,"bounds":{"left":0.97855717,"top":0.06344773,"width":0.017952127,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"lukas.kovalik@jiminny.com","depth":12,"bounds":{"left":0.98537236,"top":0.057861134,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"lukas.kovalik@jiminny.com","depth":14,"bounds":{"left":0.99052525,"top":0.06344773,"width":0.009474754,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"For you","depth":12,"bounds":{"left":0.08361037,"top":0.09976058,"width":0.071476065,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"For you","depth":15,"bounds":{"left":0.09424867,"top":0.10574621,"width":0.01662234,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Recent","depth":12,"bounds":{"left":0.08361037,"top":0.12529927,"width":0.071476065,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Recent","depth":15,"bounds":{"left":0.09424867,"top":0.13128492,"width":0.015458777,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Starred","depth":12,"bounds":{"left":0.08361037,"top":0.15083799,"width":0.071476065,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Starred","depth":15,"bounds":{"left":0.09424867,"top":0.15682362,"width":0.016456118,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Apps","depth":12,"bounds":{"left":0.08361037,"top":0.1763767,"width":0.071476065,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Apps","depth":15,"bounds":{"left":0.09424867,"top":0.18236233,"width":0.011635638,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"More actions for Apps","depth":13,"bounds":{"left":0.15309176,"top":0.17956904,"width":0.0039893617,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"More actions for Apps","depth":15,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Spaces","depth":12,"bounds":{"left":0.08361037,"top":0.2019154,"width":0.071476065,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":true},{"role":"AXStaticText","text":"Spaces","depth":15,"bounds":{"left":0.09424867,"top":0.20790103,"width":0.016456118,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Create space","depth":13,"bounds":{"left":0.13646941,"top":0.20510775,"width":0.007978723,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Create space","depth":15,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"More actions for spaces","depth":13,"bounds":{"left":0.14577793,"top":0.20510775,"width":0.007978723,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"More actions for spaces","depth":15,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Recent","depth":16,"bounds":{"left":0.08959442,"top":0.23423783,"width":0.013464096,"height":0.011971269},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Jiminny (New)","depth":17,"bounds":{"left":0.08759973,"top":0.2529928,"width":0.0674867,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Jiminny (New)","depth":20,"bounds":{"left":0.09823803,"top":0.25897846,"width":0.032081116,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Jiminny (New)","depth":18,"bounds":{"left":0.08892952,"top":0.25618514,"width":0.007978723,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":true},{"role":"AXMenuButton","text":"Create board","depth":18,"bounds":{"left":0.13646941,"top":0.25618514,"width":0.007978723,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Create board","depth":20,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"More actions for Jiminny (New)","depth":18,"bounds":{"left":0.14577793,"top":0.25618514,"width":0.007978723,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"More actions for Jiminny (New)","depth":20,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Platform Team","depth":19,"bounds":{"left":0.09158909,"top":0.27853152,"width":0.06349734,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Platform Team","depth":22,"bounds":{"left":0.1022274,"top":0.28451717,"width":0.032247342,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"Board actions","depth":20,"bounds":{"left":0.15309176,"top":0.28172386,"width":0.0039893617,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Board actions","depth":22,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Capture Team","depth":19,"bounds":{"left":0.09158909,"top":0.30407023,"width":0.06349734,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Capture Team","depth":22,"bounds":{"left":0.1022274,"top":0.31005585,"width":0.03125,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"Board actions","depth":20,"bounds":{"left":0.15309176,"top":0.30726257,"width":0.0039893617,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Board actions","depth":22,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Enterprise Stability Issues 🤕","depth":19,"bounds":{"left":0.09158909,"top":0.32960895,"width":0.06349734,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Enterprise Stability Issues 🤕","depth":22,"bounds":{"left":0.1022274,"top":0.33559456,"width":0.050531916,"height":0.030726258},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"Board actions","depth":20,"bounds":{"left":0.15309176,"top":0.33280128,"width":0.0039893617,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Board actions","depth":22,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Processing Team","depth":19,"bounds":{"left":0.09158909,"top":0.35514766,"width":0.06349734,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Processing Team","depth":22,"bounds":{"left":0.1022274,"top":0.36113328,"width":0.038231384,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"Board actions","depth":20,"bounds":{"left":0.15309176,"top":0.35834,"width":0.0039893617,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Board actions","depth":22,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"SE Kanban","depth":19,"bounds":{"left":0.09158909,"top":0.38068634,"width":0.06349734,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"SE Kanban","depth":22,"bounds":{"left":0.1022274,"top":0.386672,"width":0.024102394,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"Board actions","depth":20,"bounds":{"left":0.15309176,"top":0.38387868,"width":0.0039893617,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Board actions","depth":22,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Service-Desk","depth":17,"bounds":{"left":0.08759973,"top":0.40622506,"width":0.0674867,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Service-Desk","depth":20,"bounds":{"left":0.09823803,"top":0.4122107,"width":0.03025266,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"More actions for Service-Desk","depth":18,"bounds":{"left":0.15442154,"top":0.4094174,"width":0.0039893617,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"More actions for Service-Desk","depth":20,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"More spaces","depth":17,"bounds":{"left":0.08759973,"top":0.43176377,"width":0.0674867,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"More spaces","depth":20,"bounds":{"left":0.09823803,"top":0.43774942,"width":0.028756648,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Filters","depth":12,"bounds":{"left":0.08361037,"top":0.45730248,"width":0.071476065,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Filters","depth":15,"bounds":{"left":0.09424867,"top":0.4632881,"width":0.013796543,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"More actions for Filters","depth":13,"bounds":{"left":0.15309176,"top":0.46049482,"width":0.0039893617,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"More actions for Filters","depth":15,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Dashboards","depth":12,"bounds":{"left":0.08361037,"top":0.4828412,"width":0.071476065,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Dashboards","depth":15,"bounds":{"left":0.09424867,"top":0.4888268,"width":0.026761968,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Create dashboard","depth":13,"bounds":{"left":0.15508644,"top":0.48603353,"width":0.007978723,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Create dashboard","depth":15,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"More actions for Dashboards","depth":13,"bounds":{"left":0.16240026,"top":0.48603353,"width":0.0039893617,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"More actions for Dashboards","depth":15,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Operations","depth":12,"bounds":{"left":0.08361037,"top":0.5083799,"width":0.071476065,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Operations","depth":15,"bounds":{"left":0.09424867,"top":0.5143655,"width":0.02443484,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"More actions for Operations","depth":13,"bounds":{"left":0.15309176,"top":0.51157224,"width":0.0039893617,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"More actions for Operations","depth":15,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Assets , (opens new window)","depth":13,"bounds":{"left":0.08361037,"top":0.5434956,"width":0.071476065,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Assets","depth":17,"bounds":{"left":0.09424867,"top":0.5494813,"width":0.01512633,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":", (opens new window)","depth":15,"bounds":{"left":0.08361037,"top":0.55706304,"width":0.04837101,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Teams , (opens new window)","depth":13,"bounds":{"left":0.08361037,"top":0.56903434,"width":0.071476065,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Teams","depth":17,"bounds":{"left":0.09424867,"top":0.57501996,"width":0.014793883,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":", (opens new window)","depth":15,"bounds":{"left":0.08361037,"top":0.5826017,"width":0.04837101,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"open menu","depth":14,"bounds":{"left":0.14378324,"top":0.57222664,"width":0.0039893617,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"open menu","depth":16,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Customise sidebar","depth":12,"bounds":{"left":0.08361037,"top":0.60415006,"width":0.071476065,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Customise sidebar","depth":15,"bounds":{"left":0.09424867,"top":0.6101357,"width":0.04155585,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Resize side navigation panel","depth":13,"bounds":{"left":0.2109375,"top":0.0981644,"width":0.062333778,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Spaces","depth":15,"bounds":{"left":0.27260637,"top":0.10933759,"width":0.013962766,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Spaces","depth":17,"bounds":{"left":0.27260637,"top":0.11292897,"width":0.013962766,"height":0.011971269},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"/","depth":15,"bounds":{"left":0.2883976,"top":0.11173184,"width":0.0016622341,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Jiminny (New) Jiminny (New)","depth":15,"bounds":{"left":0.29388297,"top":0.10933759,"width":0.034408245,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Jiminny (New)","depth":17,"bounds":{"left":0.3011968,"top":0.11292897,"width":0.027094414,"height":0.011971269},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"/","depth":15,"bounds":{"left":0.33011967,"top":0.11173184,"width":0.0016622341,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"Epic - Change parent","depth":15,"bounds":{"left":0.3336104,"top":0.10933759,"width":0.007978723,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"JY-12249","depth":15,"bounds":{"left":0.3415891,"top":0.10933759,"width":0.017952127,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"JY-12249","depth":17,"bounds":{"left":0.3415891,"top":0.11292897,"width":0.017952127,"height":0.011971269},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"/","depth":15,"bounds":{"left":0.36136967,"top":0.11173184,"width":0.0016622341,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"Bug - Change work type","depth":15,"bounds":{"left":0.3648604,"top":0.10933759,"width":0.007978723,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"JY-20813","depth":15,"bounds":{"left":0.3728391,"top":0.10933759,"width":0.017952127,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"JY-20813","depth":17,"bounds":{"left":0.3728391,"top":0.11292897,"width":0.017952127,"height":0.011971269},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Copy link","depth":16,"bounds":{"left":0.38946143,"top":0.11213089,"width":0.005319149,"height":0.012769354},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"Twilio number is not released when Voice role is removed from user- Summary, edit","depth":11,"on_screen":false,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXHeading","text":"Twilio number is not released when Voice role is removed from user","depth":11,"bounds":{"left":0.27327126,"top":0.1396648,"width":0.25149602,"height":0.022346368},"on_screen":true,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Twilio number is not released when Voice role is removed from user","depth":12,"bounds":{"left":0.27327126,"top":0.13926576,"width":0.25149602,"height":0.023543496},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"Add or create work related to this Bug","depth":12,"bounds":{"left":0.27260637,"top":0.17158818,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Add or create work related to this Bug","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"View app actions","depth":12,"bounds":{"left":0.28590426,"top":0.17158818,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"View app actions","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXHeading","text":"Collapse Key details Key details","depth":11,"bounds":{"left":0.26462767,"top":0.20989625,"width":0.38314494,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXButton","text":"Collapse Key details","depth":13,"bounds":{"left":0.2632979,"top":0.21308859,"width":0.007978723,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Collapse Key details","depth":15,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Key details","depth":14,"bounds":{"left":0.27260637,"top":0.2150838,"width":0.028091755,"height":0.01556265},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXHeading","text":"Description","depth":12,"bounds":{"left":0.27260637,"top":0.2386273,"width":0.02543218,"height":0.014764565},"on_screen":true,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Description","depth":13,"bounds":{"left":0.27260637,"top":0.23902634,"width":0.02543218,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Add Description, edit","depth":13,"on_screen":false,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Precondition:","depth":15,"bounds":{"left":0.27327126,"top":0.26097366,"width":0.030418882,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"User “A” has","depth":14,"bounds":{"left":0.27327126,"top":0.2897047,"width":0.028590426,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Recorder & Voice","depth":15,"bounds":{"left":0.30319148,"top":0.29090184,"width":0.0390625,"height":0.0131683955},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"role and has an assigned Twilio softphone number.","depth":14,"bounds":{"left":0.34458113,"top":0.2897047,"width":0.11153591,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"}]...
|
2405049849829003292
|
1303061663087547588
|
visual_change
|
accessibility
|
NULL
|
[JY-20963] Fix foreign key violation when matching [JY-20963] Fix foreign key violation when matching activities to deleted Salesforce opportunities - Jira
[JY-20963] Fix foreign key violation when matching activities to deleted Salesforce opportunities - Jira
[JY-20912] Fallback mechanism for users with active SF tokens for CRM Matching - Jira
[JY-20912] Fallback mechanism for users with active SF tokens for CRM Matching - Jira
JY-20915 add alias for EU by LakyLak · Pull Request #12105 · jiminny/app
JY-20915 add alias for EU by LakyLak · Pull Request #12105 · jiminny/app
Service-Desk - Queues - Platform team - Service space - Jira
Service-Desk - Queues - Platform team - Service space - Jira
JY-20676 delete AJ reports related objects by LakyLak · Pull Request #12098 · jiminny/app
JY-20676 delete AJ reports related objects by LakyLak · Pull Request #12098 · jiminny/app
JY-20915 add alias for EU by LakyLak · Pull Request #12105 · jiminny/app
JY-20915 add alias for EU by LakyLak · Pull Request #12105 · jiminny/app
Pipelines - jiminny/app
Pipelines - jiminny/app
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {"status":"error","message":"You have reached your secondly limit.","errorType":"RATE_LIMIT
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {"status":"error","message":"You have reached your secondly limit.","errorType":"RATE_LIMIT
Jiminny
Jiminny
[JY-20879] Enable users to use their new activity types - Jira
[JY-20879] Enable users to use their new activity types - Jira
Sentry error
Illuminate\Database\QueryException: SQLSTATE[23000]: Integrity constraint violation: 1452 Cannot add or update a child row: a foreign key constraint fails (`jiminny`.`activities`, CONSTRAINT `activities_opportunity_id_foreign` FOREIGN KEY (`opportunity_id`
Illuminate\Database\QueryException: SQLSTATE[23000]: Integrity constraint violation: 1452 Cannot add or update a child row: a foreign key constraint fails (`jiminny`.`activities`, CONSTRAINT `activities_opportunity_id_foreign` FOREIGN KEY (`opportunity_id`
CloudWatch | eu-west-1
CloudWatch | eu-west-1
JY-20842 add delete events for es by Vasil-Jiminny · Pull Request #12120 · jiminny/app
JY-20842 add delete events for es by Vasil-Jiminny · Pull Request #12120 · jiminny/app
Jira
Jira
Close tab
New Tab
Customize sidebar
Open Google Gemini (⌃X)
Tabs from other devices
Open history (⇧⌘H)
Open bookmarks (⌘B)
Skip to:
Sidebar
Sidebar
Top Bar
Top Bar
Main Content
Main Content
Collapse sidebar [
Collapse sidebar [
Switch sites or apps
Switch sites or apps
Go to your Jira homepage
Search, press enter to navigate to advanced search with your text query
Create
Create
Rovo Ask Rovo
Ask Rovo
8 Notifications
8 Notifications
Help
Help
Settings
Settings
[EMAIL]
[EMAIL]
For you
For you
Recent
Recent
Starred
Starred
Apps
Apps
More actions for Apps
More actions for Apps
Spaces
Spaces
Create space
Create space
More actions for spaces
More actions for spaces
Recent
Jiminny (New)
Jiminny (New)
Jiminny (New)
Create board
Create board
More actions for Jiminny (New)
More actions for Jiminny (New)
Platform Team
Platform Team
Board actions
Board actions
Capture Team
Capture Team
Board actions
Board actions
Enterprise Stability Issues 🤕
Enterprise Stability Issues 🤕
Board actions
Board actions
Processing Team
Processing Team
Board actions
Board actions
SE Kanban
SE Kanban
Board actions
Board actions
Service-Desk
Service-Desk
More actions for Service-Desk
More actions for Service-Desk
More spaces
More spaces
Filters
Filters
More actions for Filters
More actions for Filters
Dashboards
Dashboards
Create dashboard
Create dashboard
More actions for Dashboards
More actions for Dashboards
Operations
Operations
More actions for Operations
More actions for Operations
Assets , (opens new window)
Assets
, (opens new window)
Teams , (opens new window)
Teams
, (opens new window)
open menu
open menu
Customise sidebar
Customise sidebar
Resize side navigation panel
Spaces
Spaces
/
Jiminny (New) Jiminny (New)
Jiminny (New)
/
Epic - Change parent
JY-12249
JY-12249
/
Bug - Change work type
JY-20813
JY-20813
Copy link
Twilio number is not released when Voice role is removed from user- Summary, edit
Twilio number is not released when Voice role is removed from user
Twilio number is not released when Voice role is removed from user
Add or create work related to this Bug
Add or create work related to this Bug
View app actions
View app actions
Collapse Key details Key details
Collapse Key details
Collapse Key details
Key details
Description
Description
Add Description, edit
Precondition:
User “A” has
Recorder & Voice
role and has an assigned Twilio softphone number....
|
70662
|
NULL
|
NULL
|
NULL
|
|
70662
|
2537
|
22
|
2026-05-22T10:27:37.476359+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779445657476_m2.jpg...
|
Firefox
|
Jira — Work
|
1
|
jiminny.atlassian.net/browse/JY-20813?atlOrigin=ey jiminny.atlassian.net/browse/JY-20813?atlOrigin=eyJpIjoiZGEwYThkZjQ5YjI4NGUyOTkyNjZiYmU0YmJmNWU0MmEiLCJwIjoiamlyYS1zbGFjay1pbnQifQ&issueKey=JY-20813&subProduct=jira-software...
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
[JY-20963] Fix foreign key violation when matching [JY-20963] Fix foreign key violation when matching activities to deleted Salesforce opportunities - Jira
[JY-20963] Fix foreign key violation when matching activities to deleted Salesforce opportunities - Jira
[JY-20912] Fallback mechanism for users with active SF tokens for CRM Matching - Jira
[JY-20912] Fallback mechanism for users with active SF tokens for CRM Matching - Jira
JY-20915 add alias for EU by LakyLak · Pull Request #12105 · jiminny/app
JY-20915 add alias for EU by LakyLak · Pull Request #12105 · jiminny/app
Service-Desk - Queues - Platform team - Service space - Jira
Service-Desk - Queues - Platform team - Service space - Jira
JY-20676 delete AJ reports related objects by LakyLak · Pull Request #12098 · jiminny/app
JY-20676 delete AJ reports related objects by LakyLak · Pull Request #12098 · jiminny/app
JY-20915 add alias for EU by LakyLak · Pull Request #12105 · jiminny/app
JY-20915 add alias for EU by LakyLak · Pull Request #12105 · jiminny/app
Pipelines - jiminny/app
Pipelines - jiminny/app
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {"status":"error","message":"You have reached your secondly limit.","errorType":"RATE_LIMIT
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {"status":"error","message":"You have reached your secondly limit.","errorType":"RATE_LIMIT
Jiminny
Jiminny
[JY-20879] Enable users to use their new activity types - Jira
[JY-20879] Enable users to use their new activity types - Jira
Sentry error
Illuminate\Database\QueryException: SQLSTATE[23000]: Integrity constraint violation: 1452 Cannot add or update a child row: a foreign key constraint fails (`jiminny`.`activities`, CONSTRAINT `activities_opportunity_id_foreign` FOREIGN KEY (`opportunity_id`
Illuminate\Database\QueryException: SQLSTATE[23000]: Integrity constraint violation: 1452 Cannot add or update a child row: a foreign key constraint fails (`jiminny`.`activities`, CONSTRAINT `activities_opportunity_id_foreign` FOREIGN KEY (`opportunity_id`
CloudWatch | eu-west-1
CloudWatch | eu-west-1
JY-20842 add delete events for es by Vasil-Jiminny · Pull Request #12120 · jiminny/app
JY-20842 add delete events for es by Vasil-Jiminny · Pull Request #12120 · jiminny/app
Jira
Jira
Close tab
New Tab
Customize sidebar
Open Google Gemini (⌃X)
Tabs from other devices
Open history (⇧⌘H)
Open bookmarks (⌘B)
Collapse sidebar [
Collapse sidebar [
The App Switcher is loading
Go to your Jira homepage
Search, press enter to navigate to advanced search with your text query
Create
Create
Rovo Ask Rovo
Ask Rovo
8 Notifications
8 Notifications
Help
Help
Settings
Settings
[EMAIL]
[EMAIL]
For you
For you
Recent
Recent
Starred
Starred
Apps
Apps
More actions for Apps
More actions for Apps
Spaces
Spaces
Create space
Create space
More actions for spaces
More actions for spaces
Recent
Jiminny (New)
Jiminny (New)
Jiminny (New)
Create board
Create board
More actions for Jiminny (New)
More actions for Jiminny (New)
Platform Team
Platform Team
Board actions
Board actions
Capture Team
Capture Team
Board actions
Board actions
Enterprise Stability Issues 🤕
Enterprise Stability Issues 🤕
Board actions
Board actions
Processing Team
Processing Team
Board actions
Board actions
SE Kanban
SE Kanban
Board actions
Board actions
Service-Desk
Service-Desk
More actions for Service-Desk
More actions for Service-Desk
More spaces
More spaces
Filters
Filters
More actions for Filters
More actions for Filters
Dashboards
Dashboards
Create dashboard
Create dashboard
More actions for Dashboards
More actions for Dashboards
Operations
Operations
More actions for Operations
More actions for Operations
Assets , (opens new window)
Assets
, (opens new window)
Teams , (opens new window)
Teams
, (opens new window)
open menu
open menu
Customise sidebar
Customise sidebar
Spaces
Spaces
/
Jiminny (New) Jiminny (New)
Jiminny (New)
/
Epic - Change parent
JY-12249
JY-12249
/
Bug - Change work type
JY-20813
JY-20813
Copy link
Twilio number is not released when Voice role is removed from user- Summary, edit
Twilio number is not released when Voice role is removed from user
Twilio number is not released when Voice role is removed from user
Add or create work related to this Bug
Add or create work related to this Bug
View app actions
View app actions
Collapse Key details Key details
Collapse Key details
Collapse Key details
Key details
Description
Description
Add Description, edit
Precondition:
User “A” has
Recorder & Voice
role and has an assigned Twilio softphone number.
Steps to reproduce:
Log in to Jiminny as an admin user.
Navigate to Organization Settings > User page
/settings/organization/members
Remove the Voice role from user “A”...
|
[{"role":"AXRadioButton","text [{"role":"AXRadioButton","text":"[JY-20963] Fix foreign key violation when matching activities to deleted Salesforce opportunities - Jira","depth":4,"bounds":{"left":0.0,"top":0.0518755,"width":0.07962101,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"[JY-20963] Fix foreign key violation when matching activities to deleted Salesforce opportunities - Jira","depth":5,"bounds":{"left":0.013297873,"top":0.06304868,"width":0.17902261,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"[JY-20912] Fallback mechanism for users with active SF tokens for CRM Matching - Jira","depth":4,"bounds":{"left":0.0,"top":0.08459697,"width":0.07962101,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"[JY-20912] Fallback mechanism for users with active SF tokens for CRM Matching - Jira","depth":5,"bounds":{"left":0.013297873,"top":0.09577015,"width":0.15259309,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"JY-20915 add alias for EU by LakyLak · Pull Request #12105 · jiminny/app","depth":4,"bounds":{"left":0.0,"top":0.11731844,"width":0.07962101,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"JY-20915 add alias for EU by LakyLak · Pull Request #12105 · jiminny/app","depth":5,"bounds":{"left":0.013297873,"top":0.12849163,"width":0.12699468,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Service-Desk - Queues - Platform team - Service space - Jira","depth":4,"bounds":{"left":0.0,"top":0.15003991,"width":0.07962101,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Service-Desk - Queues - Platform team - Service space - Jira","depth":5,"bounds":{"left":0.013297873,"top":0.16121309,"width":0.10721409,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"JY-20676 delete AJ reports related objects by LakyLak · Pull Request #12098 · jiminny/app","depth":4,"bounds":{"left":0.0,"top":0.18276137,"width":0.07962101,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"JY-20676 delete AJ reports related objects by LakyLak · Pull Request #12098 · jiminny/app","depth":5,"bounds":{"left":0.013297873,"top":0.19393456,"width":0.15791224,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"JY-20915 add alias for EU by LakyLak · Pull Request #12105 · jiminny/app","depth":4,"bounds":{"left":0.0,"top":0.21548285,"width":0.07962101,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"JY-20915 add alias for EU by LakyLak · Pull Request #12105 · jiminny/app","depth":5,"bounds":{"left":0.013297873,"top":0.22665602,"width":0.12699468,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Pipelines - jiminny/app","depth":4,"bounds":{"left":0.0,"top":0.2482043,"width":0.07962101,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Pipelines - jiminny/app","depth":5,"bounds":{"left":0.013297873,"top":0.25937748,"width":0.039228722,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"SevenShores\\Hubspot\\Exceptions\\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {\"status\":\"error\",\"message\":\"You have reached your secondly limit.\",\"errorType\":\"RATE_LIMIT","depth":4,"bounds":{"left":0.0,"top":0.28092578,"width":0.07962101,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"SevenShores\\Hubspot\\Exceptions\\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {\"status\":\"error\",\"message\":\"You have reached your secondly limit.\",\"errorType\":\"RATE_LIMIT","depth":5,"bounds":{"left":0.013297873,"top":0.29209897,"width":0.4644282,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Jiminny","depth":4,"bounds":{"left":0.0,"top":0.31364724,"width":0.07962101,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Jiminny","depth":5,"bounds":{"left":0.013297873,"top":0.32482043,"width":0.013131649,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"[JY-20879] Enable users to use their new activity types - Jira","depth":4,"bounds":{"left":0.0,"top":0.3463687,"width":0.07962101,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"[JY-20879] Enable users to use their new activity types - Jira","depth":5,"bounds":{"left":0.013297873,"top":0.3575419,"width":0.106715426,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Sentry error","depth":4,"bounds":{"left":0.0028257978,"top":0.38427773,"width":0.024601065,"height":0.01915403},"on_screen":true,"role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":true},{"role":"AXRadioButton","text":"Illuminate\\Database\\QueryException: SQLSTATE[23000]: Integrity constraint violation: 1452 Cannot add or update a child row: a foreign key constraint fails (`jiminny`.`activities`, CONSTRAINT `activities_opportunity_id_foreign` FOREIGN KEY (`opportunity_id`","depth":4,"bounds":{"left":0.0028257978,"top":0.40782124,"width":0.07679521,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Illuminate\\Database\\QueryException: SQLSTATE[23000]: Integrity constraint violation: 1452 Cannot add or update a child row: a foreign key constraint fails (`jiminny`.`activities`, CONSTRAINT `activities_opportunity_id_foreign` FOREIGN KEY (`opportunity_id`","depth":5,"bounds":{"left":0.015957447,"top":0.41899443,"width":0.45362368,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"CloudWatch | eu-west-1","depth":4,"bounds":{"left":0.0028257978,"top":0.4405427,"width":0.07679521,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"CloudWatch | eu-west-1","depth":5,"bounds":{"left":0.015957447,"top":0.4517159,"width":0.041722074,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"JY-20842 add delete events for es by Vasil-Jiminny · Pull Request #12120 · jiminny/app","depth":4,"bounds":{"left":0.0,"top":0.47326416,"width":0.07962101,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"JY-20842 add delete events for es by Vasil-Jiminny · Pull Request #12120 · jiminny/app","depth":5,"bounds":{"left":0.013297873,"top":0.48443735,"width":0.15109707,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Jira","depth":4,"bounds":{"left":0.0,"top":0.5059856,"width":0.07962101,"height":0.032721467},"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":true},{"role":"AXStaticText","text":"Jira","depth":5,"bounds":{"left":0.013297873,"top":0.5171588,"width":0.0063164895,"height":0.010774142},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Close tab","depth":5,"bounds":{"left":0.06732048,"top":0.5131684,"width":0.007978723,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"New Tab","depth":4,"bounds":{"left":0.0028257978,"top":0.5403033,"width":0.07413564,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Customize sidebar","depth":6,"bounds":{"left":0.0028257978,"top":0.97007185,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Open Google Gemini (⌃X)","depth":6,"bounds":{"left":0.013796543,"top":0.97007185,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Tabs from other devices","depth":6,"bounds":{"left":0.024933511,"top":0.97007185,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Open history (⇧⌘H)","depth":6,"bounds":{"left":0.036070477,"top":0.97007185,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Open bookmarks (⌘B)","depth":6,"bounds":{"left":0.04720745,"top":0.97007185,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"Collapse sidebar [","depth":9,"bounds":{"left":0.08361037,"top":0.057861134,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Collapse sidebar [","depth":11,"bounds":{"left":0.0887633,"top":0.06344773,"width":0.039727394,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"The App Switcher is loading","depth":9,"bounds":{"left":0.095578454,"top":0.057861134,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"Go to your Jira homepage","depth":9,"bounds":{"left":0.10887633,"top":0.057861134,"width":0.029421542,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXComboBox","text":"Search, press enter to navigate to advanced search with your text query","depth":11,"bounds":{"left":0.40475398,"top":0.06264964,"width":0.24268617,"height":0.015961692},"on_screen":true,"help_text":"","placeholder":"Search","role_description":"combo box","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Create","depth":10,"bounds":{"left":0.65575135,"top":0.057861134,"width":0.030086435,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Create","depth":12,"bounds":{"left":0.66705453,"top":0.06384677,"width":0.014793883,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Rovo Ask Rovo","depth":12,"bounds":{"left":0.91223407,"top":0.057861134,"width":0.035904255,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Ask Rovo","depth":14,"bounds":{"left":0.92353725,"top":0.06384677,"width":0.020611702,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"8 Notifications","depth":12,"bounds":{"left":0.9494681,"top":0.057861134,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"8 Notifications","depth":14,"bounds":{"left":0.954621,"top":0.06344773,"width":0.031914894,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Help","depth":12,"bounds":{"left":0.96143615,"top":0.057861134,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Help","depth":14,"bounds":{"left":0.9665891,"top":0.06344773,"width":0.010139627,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"Settings","depth":12,"bounds":{"left":0.9734042,"top":0.057861134,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Settings","depth":14,"bounds":{"left":0.97855717,"top":0.06344773,"width":0.017952127,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"lukas.kovalik@jiminny.com","depth":12,"bounds":{"left":0.98537236,"top":0.057861134,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"lukas.kovalik@jiminny.com","depth":14,"bounds":{"left":0.99052525,"top":0.06344773,"width":0.009474754,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"For you","depth":12,"bounds":{"left":0.08361037,"top":0.09976058,"width":0.071476065,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"For you","depth":15,"bounds":{"left":0.09424867,"top":0.10574621,"width":0.01662234,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Recent","depth":12,"bounds":{"left":0.08361037,"top":0.12529927,"width":0.071476065,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Recent","depth":15,"bounds":{"left":0.09424867,"top":0.13128492,"width":0.015458777,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Starred","depth":12,"bounds":{"left":0.08361037,"top":0.15083799,"width":0.071476065,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Starred","depth":15,"bounds":{"left":0.09424867,"top":0.15682362,"width":0.016456118,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Apps","depth":12,"bounds":{"left":0.08361037,"top":0.1763767,"width":0.071476065,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Apps","depth":15,"bounds":{"left":0.09424867,"top":0.18236233,"width":0.011635638,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"More actions for Apps","depth":13,"bounds":{"left":0.15309176,"top":0.17956904,"width":0.0039893617,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"More actions for Apps","depth":15,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Spaces","depth":12,"bounds":{"left":0.08361037,"top":0.2019154,"width":0.071476065,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":true},{"role":"AXStaticText","text":"Spaces","depth":15,"bounds":{"left":0.09424867,"top":0.20790103,"width":0.016456118,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Create space","depth":13,"bounds":{"left":0.13646941,"top":0.20510775,"width":0.007978723,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Create space","depth":15,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"More actions for spaces","depth":13,"bounds":{"left":0.14577793,"top":0.20510775,"width":0.007978723,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"More actions for spaces","depth":15,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Recent","depth":16,"bounds":{"left":0.08959442,"top":0.23423783,"width":0.013464096,"height":0.011971269},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Jiminny (New)","depth":17,"bounds":{"left":0.08759973,"top":0.2529928,"width":0.0674867,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Jiminny (New)","depth":20,"bounds":{"left":0.09823803,"top":0.25897846,"width":0.032081116,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Jiminny (New)","depth":18,"bounds":{"left":0.08892952,"top":0.25618514,"width":0.007978723,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":true},{"role":"AXMenuButton","text":"Create board","depth":18,"bounds":{"left":0.13646941,"top":0.25618514,"width":0.007978723,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Create board","depth":20,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"More actions for Jiminny (New)","depth":18,"bounds":{"left":0.14577793,"top":0.25618514,"width":0.007978723,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"More actions for Jiminny (New)","depth":20,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Platform Team","depth":19,"bounds":{"left":0.09158909,"top":0.27853152,"width":0.06349734,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Platform Team","depth":22,"bounds":{"left":0.1022274,"top":0.28451717,"width":0.032247342,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"Board actions","depth":20,"bounds":{"left":0.15309176,"top":0.28172386,"width":0.0039893617,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Board actions","depth":22,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Capture Team","depth":19,"bounds":{"left":0.09158909,"top":0.30407023,"width":0.06349734,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Capture Team","depth":22,"bounds":{"left":0.1022274,"top":0.31005585,"width":0.03125,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"Board actions","depth":20,"bounds":{"left":0.15309176,"top":0.30726257,"width":0.0039893617,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Board actions","depth":22,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Enterprise Stability Issues 🤕","depth":19,"bounds":{"left":0.09158909,"top":0.32960895,"width":0.06349734,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Enterprise Stability Issues 🤕","depth":22,"bounds":{"left":0.1022274,"top":0.33559456,"width":0.050531916,"height":0.030726258},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"Board actions","depth":20,"bounds":{"left":0.15309176,"top":0.33280128,"width":0.0039893617,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Board actions","depth":22,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Processing Team","depth":19,"bounds":{"left":0.09158909,"top":0.35514766,"width":0.06349734,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Processing Team","depth":22,"bounds":{"left":0.1022274,"top":0.36113328,"width":0.038231384,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"Board actions","depth":20,"bounds":{"left":0.15309176,"top":0.35834,"width":0.0039893617,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Board actions","depth":22,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"SE Kanban","depth":19,"bounds":{"left":0.09158909,"top":0.38068634,"width":0.06349734,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"SE Kanban","depth":22,"bounds":{"left":0.1022274,"top":0.386672,"width":0.024102394,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"Board actions","depth":20,"bounds":{"left":0.15309176,"top":0.38387868,"width":0.0039893617,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Board actions","depth":22,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Service-Desk","depth":17,"bounds":{"left":0.08759973,"top":0.40622506,"width":0.0674867,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Service-Desk","depth":20,"bounds":{"left":0.09823803,"top":0.4122107,"width":0.03025266,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"More actions for Service-Desk","depth":18,"bounds":{"left":0.15442154,"top":0.4094174,"width":0.0039893617,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"More actions for Service-Desk","depth":20,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"More spaces","depth":17,"bounds":{"left":0.08759973,"top":0.43176377,"width":0.0674867,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"More spaces","depth":20,"bounds":{"left":0.09823803,"top":0.43774942,"width":0.028756648,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Filters","depth":12,"bounds":{"left":0.08361037,"top":0.45730248,"width":0.071476065,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Filters","depth":15,"bounds":{"left":0.09424867,"top":0.4632881,"width":0.013796543,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"More actions for Filters","depth":13,"bounds":{"left":0.15309176,"top":0.46049482,"width":0.0039893617,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"More actions for Filters","depth":15,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Dashboards","depth":12,"bounds":{"left":0.08361037,"top":0.4828412,"width":0.071476065,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Dashboards","depth":15,"bounds":{"left":0.09424867,"top":0.4888268,"width":0.026761968,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Create dashboard","depth":13,"bounds":{"left":0.15508644,"top":0.48603353,"width":0.007978723,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Create dashboard","depth":15,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"More actions for Dashboards","depth":13,"bounds":{"left":0.16240026,"top":0.48603353,"width":0.0039893617,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"More actions for Dashboards","depth":15,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Operations","depth":12,"bounds":{"left":0.08361037,"top":0.5083799,"width":0.071476065,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Operations","depth":15,"bounds":{"left":0.09424867,"top":0.5143655,"width":0.02443484,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"More actions for Operations","depth":13,"bounds":{"left":0.15309176,"top":0.51157224,"width":0.0039893617,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"More actions for Operations","depth":15,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Assets , (opens new window)","depth":13,"bounds":{"left":0.08361037,"top":0.5434956,"width":0.071476065,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Assets","depth":17,"bounds":{"left":0.09424867,"top":0.5494813,"width":0.01512633,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":", (opens new window)","depth":15,"bounds":{"left":0.08361037,"top":0.55706304,"width":0.04837101,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Teams , (opens new window)","depth":13,"bounds":{"left":0.08361037,"top":0.56903434,"width":0.071476065,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Teams","depth":17,"bounds":{"left":0.09424867,"top":0.57501996,"width":0.014793883,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":", (opens new window)","depth":15,"bounds":{"left":0.08361037,"top":0.5826017,"width":0.04837101,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"open menu","depth":14,"bounds":{"left":0.14378324,"top":0.57222664,"width":0.0039893617,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"open menu","depth":16,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Customise sidebar","depth":12,"bounds":{"left":0.08361037,"top":0.60415006,"width":0.071476065,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Customise sidebar","depth":15,"bounds":{"left":0.09424867,"top":0.6101357,"width":0.04155585,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Spaces","depth":15,"bounds":{"left":0.27260637,"top":0.10933759,"width":0.013962766,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Spaces","depth":17,"bounds":{"left":0.27260637,"top":0.11292897,"width":0.013962766,"height":0.011971269},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"/","depth":15,"bounds":{"left":0.2883976,"top":0.11173184,"width":0.0016622341,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXLink","text":"Jiminny (New) Jiminny (New)","depth":15,"bounds":{"left":0.29388297,"top":0.10933759,"width":0.034408245,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Jiminny (New)","depth":17,"bounds":{"left":0.3011968,"top":0.11292897,"width":0.027094414,"height":0.011971269},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"/","depth":15,"bounds":{"left":0.33011967,"top":0.11173184,"width":0.0016622341,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"Epic - Change parent","depth":15,"bounds":{"left":0.3336104,"top":0.10933759,"width":0.007978723,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"JY-12249","depth":15,"bounds":{"left":0.3415891,"top":0.10933759,"width":0.017952127,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"JY-12249","depth":17,"bounds":{"left":0.3415891,"top":0.11292897,"width":0.017952127,"height":0.011971269},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"/","depth":15,"bounds":{"left":0.36136967,"top":0.11173184,"width":0.0016622341,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"Bug - Change work type","depth":15,"bounds":{"left":0.3648604,"top":0.10933759,"width":0.007978723,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"JY-20813","depth":15,"bounds":{"left":0.3728391,"top":0.10933759,"width":0.017952127,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"link","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"JY-20813","depth":17,"bounds":{"left":0.3728391,"top":0.11292897,"width":0.017952127,"height":0.011971269},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Copy link","depth":16,"bounds":{"left":0.38946143,"top":0.11213089,"width":0.005319149,"height":0.012769354},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"Twilio number is not released when Voice role is removed from user- Summary, edit","depth":11,"on_screen":false,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXHeading","text":"Twilio number is not released when Voice role is removed from user","depth":11,"bounds":{"left":0.27327126,"top":0.1396648,"width":0.25149602,"height":0.022346368},"on_screen":true,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Twilio number is not released when Voice role is removed from user","depth":12,"bounds":{"left":0.27327126,"top":0.13926576,"width":0.25149602,"height":0.023543496},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"Add or create work related to this Bug","depth":12,"bounds":{"left":0.27260637,"top":0.17158818,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Add or create work related to this Bug","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXMenuButton","text":"View app actions","depth":12,"bounds":{"left":0.28590426,"top":0.17158818,"width":0.010638298,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"menu button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"View app actions","depth":14,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXHeading","text":"Collapse Key details Key details","depth":11,"bounds":{"left":0.26462767,"top":0.20989625,"width":0.38314494,"height":0.025538707},"on_screen":true,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXButton","text":"Collapse Key details","depth":13,"bounds":{"left":0.2632979,"top":0.21308859,"width":0.007978723,"height":0.01915403},"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Collapse Key details","depth":15,"on_screen":false,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Key details","depth":14,"bounds":{"left":0.27260637,"top":0.2150838,"width":0.028091755,"height":0.01556265},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXHeading","text":"Description","depth":12,"bounds":{"left":0.27260637,"top":0.2386273,"width":0.02543218,"height":0.014764565},"on_screen":true,"help_text":"","role_description":"heading","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Description","depth":13,"bounds":{"left":0.27260637,"top":0.23902634,"width":0.02543218,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Add Description, edit","depth":13,"on_screen":false,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Precondition:","depth":15,"bounds":{"left":0.27327126,"top":0.26097366,"width":0.030418882,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"User “A” has","depth":14,"bounds":{"left":0.27327126,"top":0.2897047,"width":0.028590426,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Recorder & Voice","depth":15,"bounds":{"left":0.30319148,"top":0.29090184,"width":0.0390625,"height":0.0131683955},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"role and has an assigned Twilio softphone number.","depth":14,"bounds":{"left":0.34458113,"top":0.2897047,"width":0.11153591,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Steps to reproduce:","depth":15,"bounds":{"left":0.27327126,"top":0.31843576,"width":0.044714097,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Log in to Jiminny as an admin user.","depth":16,"bounds":{"left":0.28125,"top":0.3471668,"width":0.07712766,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Navigate to Organization Settings > User page","depth":16,"bounds":{"left":0.28125,"top":0.36951315,"width":0.101894945,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"/settings/organization/members","depth":17,"bounds":{"left":0.28241357,"top":0.38986433,"width":0.07330452,"height":0.0131683955},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXStaticText","text":"Remove the Voice role from user “A”","depth":16,"bounds":{"left":0.28125,"top":0.41101357,"width":0.07962101,"height":0.01396648},"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"}]...
|
-3259632451650352931
|
5910244082963403974
|
visual_change
|
accessibility
|
NULL
|
[JY-20963] Fix foreign key violation when matching [JY-20963] Fix foreign key violation when matching activities to deleted Salesforce opportunities - Jira
[JY-20963] Fix foreign key violation when matching activities to deleted Salesforce opportunities - Jira
[JY-20912] Fallback mechanism for users with active SF tokens for CRM Matching - Jira
[JY-20912] Fallback mechanism for users with active SF tokens for CRM Matching - Jira
JY-20915 add alias for EU by LakyLak · Pull Request #12105 · jiminny/app
JY-20915 add alias for EU by LakyLak · Pull Request #12105 · jiminny/app
Service-Desk - Queues - Platform team - Service space - Jira
Service-Desk - Queues - Platform team - Service space - Jira
JY-20676 delete AJ reports related objects by LakyLak · Pull Request #12098 · jiminny/app
JY-20676 delete AJ reports related objects by LakyLak · Pull Request #12098 · jiminny/app
JY-20915 add alias for EU by LakyLak · Pull Request #12105 · jiminny/app
JY-20915 add alias for EU by LakyLak · Pull Request #12105 · jiminny/app
Pipelines - jiminny/app
Pipelines - jiminny/app
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {"status":"error","message":"You have reached your secondly limit.","errorType":"RATE_LIMIT
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {"status":"error","message":"You have reached your secondly limit.","errorType":"RATE_LIMIT
Jiminny
Jiminny
[JY-20879] Enable users to use their new activity types - Jira
[JY-20879] Enable users to use their new activity types - Jira
Sentry error
Illuminate\Database\QueryException: SQLSTATE[23000]: Integrity constraint violation: 1452 Cannot add or update a child row: a foreign key constraint fails (`jiminny`.`activities`, CONSTRAINT `activities_opportunity_id_foreign` FOREIGN KEY (`opportunity_id`
Illuminate\Database\QueryException: SQLSTATE[23000]: Integrity constraint violation: 1452 Cannot add or update a child row: a foreign key constraint fails (`jiminny`.`activities`, CONSTRAINT `activities_opportunity_id_foreign` FOREIGN KEY (`opportunity_id`
CloudWatch | eu-west-1
CloudWatch | eu-west-1
JY-20842 add delete events for es by Vasil-Jiminny · Pull Request #12120 · jiminny/app
JY-20842 add delete events for es by Vasil-Jiminny · Pull Request #12120 · jiminny/app
Jira
Jira
Close tab
New Tab
Customize sidebar
Open Google Gemini (⌃X)
Tabs from other devices
Open history (⇧⌘H)
Open bookmarks (⌘B)
Collapse sidebar [
Collapse sidebar [
The App Switcher is loading
Go to your Jira homepage
Search, press enter to navigate to advanced search with your text query
Create
Create
Rovo Ask Rovo
Ask Rovo
8 Notifications
8 Notifications
Help
Help
Settings
Settings
[EMAIL]
[EMAIL]
For you
For you
Recent
Recent
Starred
Starred
Apps
Apps
More actions for Apps
More actions for Apps
Spaces
Spaces
Create space
Create space
More actions for spaces
More actions for spaces
Recent
Jiminny (New)
Jiminny (New)
Jiminny (New)
Create board
Create board
More actions for Jiminny (New)
More actions for Jiminny (New)
Platform Team
Platform Team
Board actions
Board actions
Capture Team
Capture Team
Board actions
Board actions
Enterprise Stability Issues 🤕
Enterprise Stability Issues 🤕
Board actions
Board actions
Processing Team
Processing Team
Board actions
Board actions
SE Kanban
SE Kanban
Board actions
Board actions
Service-Desk
Service-Desk
More actions for Service-Desk
More actions for Service-Desk
More spaces
More spaces
Filters
Filters
More actions for Filters
More actions for Filters
Dashboards
Dashboards
Create dashboard
Create dashboard
More actions for Dashboards
More actions for Dashboards
Operations
Operations
More actions for Operations
More actions for Operations
Assets , (opens new window)
Assets
, (opens new window)
Teams , (opens new window)
Teams
, (opens new window)
open menu
open menu
Customise sidebar
Customise sidebar
Spaces
Spaces
/
Jiminny (New) Jiminny (New)
Jiminny (New)
/
Epic - Change parent
JY-12249
JY-12249
/
Bug - Change work type
JY-20813
JY-20813
Copy link
Twilio number is not released when Voice role is removed from user- Summary, edit
Twilio number is not released when Voice role is removed from user
Twilio number is not released when Voice role is removed from user
Add or create work related to this Bug
Add or create work related to this Bug
View app actions
View app actions
Collapse Key details Key details
Collapse Key details
Collapse Key details
Key details
Description
Description
Add Description, edit
Precondition:
User “A” has
Recorder & Voice
role and has an assigned Twilio softphone number.
Steps to reproduce:
Log in to Jiminny as an admin user.
Navigate to Organization Settings > User page
/settings/organization/members
Remove the Voice role from user “A”...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
70660
|
2536
|
25
|
2026-05-22T10:27:35.873104+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779445655873_m1.jpg...
|
Firefox
|
Jira — Work
|
1
|
jiminny.atlassian.net/browse/JY-20813?atlOrigin=ey jiminny.atlassian.net/browse/JY-20813?atlOrigin=eyJpIjoiZGEwYThkZjQ5YjI4NGUyOTkyNjZiYmU0YmJmNWU0MmEiLCJwIjoiamlyYS1zbGFjay1pbnQifQ&issueKey=JY-20813&subProduct=jira-software...
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
[JY-20963] Fix foreign key violation when matching [JY-20963] Fix foreign key violation when matching activities to deleted Salesforce opportunities - Jira
[JY-20963] Fix foreign key violation when matching activities to deleted Salesforce opportunities - Jira
[JY-20912] Fallback mechanism for users with active SF tokens for CRM Matching - Jira
[JY-20912] Fallback mechanism for users with active SF tokens for CRM Matching - Jira
JY-20915 add alias for EU by LakyLak · Pull Request #12105 · jiminny/app
JY-20915 add alias for EU by LakyLak · Pull Request #12105 · jiminny/app
Service-Desk - Queues - Platform team - Service space - Jira
Service-Desk - Queues - Platform team - Service space - Jira
JY-20676 delete AJ reports related objects by LakyLak · Pull Request #12098 · jiminny/app
JY-20676 delete AJ reports related objects by LakyLak · Pull Request #12098 · jiminny/app
JY-20915 add alias for EU by LakyLak · Pull Request #12105 · jiminny/app
JY-20915 add alias for EU by LakyLak · Pull Request #12105 · jiminny/app
Pipelines - jiminny/app
Pipelines - jiminny/app
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {"status":"error","message":"You have reached your secondly limit.","errorType":"RATE_LIMIT
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {"status":"error","message":"You have reached your secondly limit.","errorType":"RATE_LIMIT
Jiminny
Jiminny
[JY-20879] Enable users to use their new activity types - Jira
[JY-20879] Enable users to use their new activity types - Jira
Sentry error
Illuminate\Database\QueryException: SQLSTATE[23000]: Integrity constraint violation: 1452 Cannot add or update a child row: a foreign key constraint fails (`jiminny`.`activities`, CONSTRAINT `activities_opportunity_id_foreign` FOREIGN KEY (`opportunity_id`
Illuminate\Database\QueryException: SQLSTATE[23000]: Integrity constraint violation: 1452 Cannot add or update a child row: a foreign key constraint fails (`jiminny`.`activities`, CONSTRAINT `activities_opportunity_id_foreign` FOREIGN KEY (`opportunity_id`
CloudWatch | eu-west-1
CloudWatch | eu-west-1
JY-20842 add delete events for es by Vasil-Jiminny · Pull Request #12120 · jiminny/app
JY-20842 add delete events for es by Vasil-Jiminny · Pull Request #12120 · jiminny/app
Jira
Jira
Close tab
New Tab
Customize sidebar
Open Google Gemini (⌃X)
Tabs from other devices
Open history (⇧⌘H)
Open bookmarks (⌘B)
Transferring data from jiminny.atlassian.net…...
|
[{"role":"AXRadioButton","text [{"role":"AXRadioButton","text":"[JY-20963] Fix foreign key violation when matching activities to deleted Salesforce opportunities - Jira","depth":4,"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"[JY-20963] Fix foreign key violation when matching activities to deleted Salesforce opportunities - Jira","depth":5,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"[JY-20912] Fallback mechanism for users with active SF tokens for CRM Matching - Jira","depth":4,"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"[JY-20912] Fallback mechanism for users with active SF tokens for CRM Matching - Jira","depth":5,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"JY-20915 add alias for EU by LakyLak · Pull Request #12105 · jiminny/app","depth":4,"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"JY-20915 add alias for EU by LakyLak · Pull Request #12105 · jiminny/app","depth":5,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Service-Desk - Queues - Platform team - Service space - Jira","depth":4,"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Service-Desk - Queues - Platform team - Service space - Jira","depth":5,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"JY-20676 delete AJ reports related objects by LakyLak · Pull Request #12098 · jiminny/app","depth":4,"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"JY-20676 delete AJ reports related objects by LakyLak · Pull Request #12098 · jiminny/app","depth":5,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"JY-20915 add alias for EU by LakyLak · Pull Request #12105 · jiminny/app","depth":4,"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"JY-20915 add alias for EU by LakyLak · Pull Request #12105 · jiminny/app","depth":5,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Pipelines - jiminny/app","depth":4,"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Pipelines - jiminny/app","depth":5,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"SevenShores\\Hubspot\\Exceptions\\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {\"status\":\"error\",\"message\":\"You have reached your secondly limit.\",\"errorType\":\"RATE_LIMIT","depth":4,"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"SevenShores\\Hubspot\\Exceptions\\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {\"status\":\"error\",\"message\":\"You have reached your secondly limit.\",\"errorType\":\"RATE_LIMIT","depth":5,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Jiminny","depth":4,"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Jiminny","depth":5,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"[JY-20879] Enable users to use their new activity types - Jira","depth":4,"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"[JY-20879] Enable users to use their new activity types - Jira","depth":5,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Sentry error","depth":4,"on_screen":true,"role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":true},{"role":"AXRadioButton","text":"Illuminate\\Database\\QueryException: SQLSTATE[23000]: Integrity constraint violation: 1452 Cannot add or update a child row: a foreign key constraint fails (`jiminny`.`activities`, CONSTRAINT `activities_opportunity_id_foreign` FOREIGN KEY (`opportunity_id`","depth":4,"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Illuminate\\Database\\QueryException: SQLSTATE[23000]: Integrity constraint violation: 1452 Cannot add or update a child row: a foreign key constraint fails (`jiminny`.`activities`, CONSTRAINT `activities_opportunity_id_foreign` FOREIGN KEY (`opportunity_id`","depth":5,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"CloudWatch | eu-west-1","depth":4,"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"CloudWatch | eu-west-1","depth":5,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"JY-20842 add delete events for es by Vasil-Jiminny · Pull Request #12120 · jiminny/app","depth":4,"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"JY-20842 add delete events for es by Vasil-Jiminny · Pull Request #12120 · jiminny/app","depth":5,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXRadioButton","text":"Jira","depth":4,"on_screen":true,"help_text":"","role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":true},{"role":"AXStaticText","text":"Jira","depth":5,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"},{"role":"AXButton","text":"Close tab","depth":5,"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXButton","text":"New Tab","depth":4,"on_screen":true,"help_text":"","role_description":"button","subrole":"AXUnknown","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Customize sidebar","depth":6,"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Open Google Gemini (⌃X)","depth":6,"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Tabs from other devices","depth":6,"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Open history (⇧⌘H)","depth":6,"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXCheckBox","text":"Open bookmarks (⌘B)","depth":6,"on_screen":true,"help_text":"","role_description":"toggle button","subrole":"AXToggle","is_enabled":true,"is_focused":false,"is_selected":false},{"role":"AXStaticText","text":"Transferring data from jiminny.atlassian.net…","depth":5,"on_screen":true,"help_text":"","role_description":"text","subrole":"AXUnknown"}]...
|
607934294803663679
|
-9057733105826573431
|
visual_change
|
accessibility
|
NULL
|
[JY-20963] Fix foreign key violation when matching [JY-20963] Fix foreign key violation when matching activities to deleted Salesforce opportunities - Jira
[JY-20963] Fix foreign key violation when matching activities to deleted Salesforce opportunities - Jira
[JY-20912] Fallback mechanism for users with active SF tokens for CRM Matching - Jira
[JY-20912] Fallback mechanism for users with active SF tokens for CRM Matching - Jira
JY-20915 add alias for EU by LakyLak · Pull Request #12105 · jiminny/app
JY-20915 add alias for EU by LakyLak · Pull Request #12105 · jiminny/app
Service-Desk - Queues - Platform team - Service space - Jira
Service-Desk - Queues - Platform team - Service space - Jira
JY-20676 delete AJ reports related objects by LakyLak · Pull Request #12098 · jiminny/app
JY-20676 delete AJ reports related objects by LakyLak · Pull Request #12098 · jiminny/app
JY-20915 add alias for EU by LakyLak · Pull Request #12105 · jiminny/app
JY-20915 add alias for EU by LakyLak · Pull Request #12105 · jiminny/app
Pipelines - jiminny/app
Pipelines - jiminny/app
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {"status":"error","message":"You have reached your secondly limit.","errorType":"RATE_LIMIT
SevenShores\Hubspot\Exceptions\BadRequest: Client error: `POST https://api.hubapi.com/crm/v3/objects/contact/search` resulted in a `429 Too Many Requests` response: {"status":"error","message":"You have reached your secondly limit.","errorType":"RATE_LIMIT
Jiminny
Jiminny
[JY-20879] Enable users to use their new activity types - Jira
[JY-20879] Enable users to use their new activity types - Jira
Sentry error
Illuminate\Database\QueryException: SQLSTATE[23000]: Integrity constraint violation: 1452 Cannot add or update a child row: a foreign key constraint fails (`jiminny`.`activities`, CONSTRAINT `activities_opportunity_id_foreign` FOREIGN KEY (`opportunity_id`
Illuminate\Database\QueryException: SQLSTATE[23000]: Integrity constraint violation: 1452 Cannot add or update a child row: a foreign key constraint fails (`jiminny`.`activities`, CONSTRAINT `activities_opportunity_id_foreign` FOREIGN KEY (`opportunity_id`
CloudWatch | eu-west-1
CloudWatch | eu-west-1
JY-20842 add delete events for es by Vasil-Jiminny · Pull Request #12120 · jiminny/app
JY-20842 add delete events for es by Vasil-Jiminny · Pull Request #12120 · jiminny/app
Jira
Jira
Close tab
New Tab
Customize sidebar
Open Google Gemini (⌃X)
Tabs from other devices
Open history (⇧⌘H)
Open bookmarks (⌘B)
Transferring data from jiminny.atlassian.net…...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
70659
|
2536
|
24
|
2026-05-22T10:27:31.532678+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779445651532_m1.jpg...
|
iTerm2
|
NULL
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
SlackFileEditViewGoHistoryWindowHelpDOCKERDEV (-zs SlackFileEditViewGoHistoryWindowHelpDOCKERDEV (-zsh)O $82APP (-zsh)tests/Unit/Services/Mail/0ffice/EmailApiClientTest.phptests/Unit/Services/Mail/TextRelayServiceTest.phptests/Unit/Services/RecallAI/RecallAIServiceTest.php79 files changed, 4254 insertions(+), 2020 deletions(-)create mode 100644•github/claude-reviewer/no-ticket-warning.txtcreate mode.github/claude-reviewer/scripts/fetch-jira-context.mjsdelete mode100644 app/Component/ES/Processor/BatchStatusInterface.phpcreate mode100644 app/Component/ES/Processor/RedisEntityExtractor.phpdelete mode100644 app/Component/ES/Processor/Traits/WorkerIdTrait.php100644 app/Component/ES/QueuePriorityEnum.phpcreate mode100644 front-end/src/components/DealInsights/Modals/__tests__/AskAnythingModacreate mode 100644 front-end/src/components/shared/AskAnything/__tests__/__snapshots__/Asdelete mode100644 front-end/src/components/shared/AskAnything/__tests__/_create mode 100644 tests/Unit/Component/ES/Processor/RedisEntityExtractorTest.phpdelete mode 100644 tests/Unit/Component/ES/Processor/Traits/WorkerIdTraitTest.phpcreate mode100644 tests/Unit/Component/ES/QueuePriorityEnumTest.phpcreate mode 100644 tests/Unit/Notifications/Activities/SmsReceivedTest.phpcreate mode100644 tests/Unit/Services/Mail/TextRelayServiceTest.phplukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ co -b JY-20963-fix-imporSwitched to a new branch 'JY-20963-fix-import-on-deleted-entity'lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/Jiminny/app (JY-20963-fix-import-on-deleted-ent|docker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix--config=.php-cs-fixer.dist.phWhat's next:Try Docker Debug forseamless, persistent debugging tools in any container or image →Learn moreat [URL_WITH_CREDENTIALS] ~/jiminny/app (JY-20963-fix-import-on-deleted-ent|docker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.phPHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminskiandcontributors.PHP runtime: 8.3.30Running analysis on 7 cores with 10 files per process.Parallel runner is an experimental feature and may be unstable, use it at your own risk.Loaded config default from".php-cs-fixer.dist.php".5691/5691 [8100%Fixed 0 of 5691 files in 96.077 seconds, 67.00 MB memory usedWhat's next:Try Docker Debug for seamless, persistent debugging tools in anycontainer orimage →Learn moreat https://docs.docker.com/go/debug-cli/lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20963-fix-import-on-deleted-e<APP (...ED→HomeDMsActivityFilesLater...More+Jiminny ...w Starrea& jiminny-x-integrati...8platform-inner-teamChannels# ai-chapter# alerts# backend# bugs# confusion-clinic# curiosity_lab# engineering# general# happy_birthday# jiminny-bg# platform-tickets# product_launches# random# releases# sofia-office# support# thank-yous# the_people_of_jimi...• Direct messages%. Galya Dimitrova **o Nikolay YankovR. Aneliya Angelova. Stoyan TanevIomac GrshamQlahl100% C8•Fri 22 May 13:27:31Describe what you are looking forJY-20813 Jira CloudXTwilio number is not released whenVoice role is removed from userJira Cloud CDetailsConversations 1DescriptionPrecondition:User "A" has Recorder & Voice * * role and has anassigned Twilio softphone number.Steps to reproduce:1. Log in to Jiminny as an admin user.2. Navigate to Organization Settings > User page/settings/organization/members3. Remove the Voice role from user "A"4. Check the assigned number in TwilioExpected:The assigned phone number should be automaticallyreleased in Twilio.Observed:The phone number is not released in Twilio.*Note: * The issue appears to be that the number isremoved locally but not released in Twilio.• users.softphone_number is set to NULL ==>CORRECT• the related record in phone_numbers table is deleted==> CORRECTStatusOpen in Jirai Summarize...
|
NULL
|
-808004804794104406
|
NULL
|
click
|
ocr
|
NULL
|
SlackFileEditViewGoHistoryWindowHelpDOCKERDEV (-zs SlackFileEditViewGoHistoryWindowHelpDOCKERDEV (-zsh)O $82APP (-zsh)tests/Unit/Services/Mail/0ffice/EmailApiClientTest.phptests/Unit/Services/Mail/TextRelayServiceTest.phptests/Unit/Services/RecallAI/RecallAIServiceTest.php79 files changed, 4254 insertions(+), 2020 deletions(-)create mode 100644•github/claude-reviewer/no-ticket-warning.txtcreate mode.github/claude-reviewer/scripts/fetch-jira-context.mjsdelete mode100644 app/Component/ES/Processor/BatchStatusInterface.phpcreate mode100644 app/Component/ES/Processor/RedisEntityExtractor.phpdelete mode100644 app/Component/ES/Processor/Traits/WorkerIdTrait.php100644 app/Component/ES/QueuePriorityEnum.phpcreate mode100644 front-end/src/components/DealInsights/Modals/__tests__/AskAnythingModacreate mode 100644 front-end/src/components/shared/AskAnything/__tests__/__snapshots__/Asdelete mode100644 front-end/src/components/shared/AskAnything/__tests__/_create mode 100644 tests/Unit/Component/ES/Processor/RedisEntityExtractorTest.phpdelete mode 100644 tests/Unit/Component/ES/Processor/Traits/WorkerIdTraitTest.phpcreate mode100644 tests/Unit/Component/ES/QueuePriorityEnumTest.phpcreate mode 100644 tests/Unit/Notifications/Activities/SmsReceivedTest.phpcreate mode100644 tests/Unit/Services/Mail/TextRelayServiceTest.phplukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ co -b JY-20963-fix-imporSwitched to a new branch 'JY-20963-fix-import-on-deleted-entity'lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/Jiminny/app (JY-20963-fix-import-on-deleted-ent|docker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix--config=.php-cs-fixer.dist.phWhat's next:Try Docker Debug forseamless, persistent debugging tools in any container or image →Learn moreat [URL_WITH_CREDENTIALS] ~/jiminny/app (JY-20963-fix-import-on-deleted-ent|docker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.phPHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminskiandcontributors.PHP runtime: 8.3.30Running analysis on 7 cores with 10 files per process.Parallel runner is an experimental feature and may be unstable, use it at your own risk.Loaded config default from".php-cs-fixer.dist.php".5691/5691 [8100%Fixed 0 of 5691 files in 96.077 seconds, 67.00 MB memory usedWhat's next:Try Docker Debug for seamless, persistent debugging tools in anycontainer orimage →Learn moreat https://docs.docker.com/go/debug-cli/lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20963-fix-import-on-deleted-e<APP (...ED→HomeDMsActivityFilesLater...More+Jiminny ...w Starrea& jiminny-x-integrati...8platform-inner-teamChannels# ai-chapter# alerts# backend# bugs# confusion-clinic# curiosity_lab# engineering# general# happy_birthday# jiminny-bg# platform-tickets# product_launches# random# releases# sofia-office# support# thank-yous# the_people_of_jimi...• Direct messages%. Galya Dimitrova **o Nikolay YankovR. Aneliya Angelova. Stoyan TanevIomac GrshamQlahl100% C8•Fri 22 May 13:27:31Describe what you are looking forJY-20813 Jira CloudXTwilio number is not released whenVoice role is removed from userJira Cloud CDetailsConversations 1DescriptionPrecondition:User "A" has Recorder & Voice * * role and has anassigned Twilio softphone number.Steps to reproduce:1. Log in to Jiminny as an admin user.2. Navigate to Organization Settings > User page/settings/organization/members3. Remove the Voice role from user "A"4. Check the assigned number in TwilioExpected:The assigned phone number should be automaticallyreleased in Twilio.Observed:The phone number is not released in Twilio.*Note: * The issue appears to be that the number isremoved locally but not released in Twilio.• users.softphone_number is set to NULL ==>CORRECT• the related record in phone_numbers table is deleted==> CORRECTStatusOpen in Jirai Summarize...
|
70658
|
NULL
|
NULL
|
NULL
|
|
70661
|
2537
|
21
|
2026-05-22T10:27:31.432116+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779445651432_m2.jpg...
|
Slack
|
platform-inner-team (Channel) - Jiminny Inc - 2 ne platform-inner-team (Channel) - Jiminny Inc - 2 new items - Slack...
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
PnostormFV faVsco.js°9 JY-20963-fix-irProiect• m Z PnostormFV faVsco.js°9 JY-20963-fix-irProiect• m Zoom• M ZoomBotcoomrnonec ActivitvCrmFieldsResolverlest.ono© ActivityProviderClientTest.php©) ActivityProviderRegistryTest.php© ActivityProviderServiceTest.php© CallDenormalizerRegistryTest.phpg crmownerkesolverlest.onpc) Meeuingbotserviceonmeeuingstaneoc MeeuingbotserviceonsharingData lesc) Meeungbotservicekequestkecoraingo) PanicipantConsencservicelesc.onoe) Panicioanisservicelest.onoc) ResponseValidationTraitTest.onoc) TrackRecordingFileSizeServiceTest.or150 V5c) TrackRecordingSizeEntorcerTest.onoc)ValidateEmitProspectEventTraitTest.r199 % >ActivitvProviders•Avatar225 vsN CalendanN Conference254V0 CrmM Bullhorn201 VCloseM Conner• M CrmOhiectsn Helpers• D HubspotD IntegrationApp403 %• hlictenercPipedrive435 %• MSalesforce> C Fields> 0 OpportunityMatcher478 YSLocal changesConcole yLog Xv Chandes A filles=env.local annC.liminnvDebuaCommand.nhn ann/Console/Commandsphp logging.php config© Service.php app/Services/Crm/Salesforce• Unversioned Siles 9 filedC ServiceTest.php X PlaybookRepository.phpT DeleteObjectsTrait.php© DeleteOpportunityJob.php= custom.log= laravel.log« SF jiminny@localhost]4 HS_local [jiminny@localhost]• SoftDeletes.phpyMatchactvitycrmbata.ong© CrmActivityService.php© RecordSelector.phpo Acuivily.onptii crm_configurations [EU] X A console [STAGING]siohp14 432 H168 V1 V.28 A V1rowvOU+declare strict-cypes=l)namespace Tests Unit Services CrmChangelist: Changesv V MServices/Crm/Salesforce 1 fileAuthor:ozyosbeyad/use...v © Service.phpAmend commit> _ U config 1 fileSian-oft commitclass Servicelest extends Testcase= env.local1 modifieduse TestPrivatelethod:Commit checksCommit MessageJY-20963Update copvriahioublic function testfetchAndAs:Poformat codeoublic function testFetchAndAs.Rearrange codeOouimize impor'spublic function testFetchAndAs:v Diftpublic function testFetchAndAs:TJ<→Do not ignore4 omerenceso laee/aaoCurrent versionpublic function testFetchAndAs:$this->handle0bjectDeletion(Slead, $crmDatalesforce..Sthis->handle0bjectDeletion(Slead, ScrmDatd)pUbLic tunccion cesthacchexacypublic function testMatchlomas.netunn Slead.19971087 Mif (Slead->trashed0) {neturn null.1090public tunction testbulldlaskspublic tunction testhapurmubnereturn $lead;$this->importOpportunityCrmFieldData($1433public tunction testbetinstallnuolic tunction testsuncprotilCancecommit': "3m" "oppulonnortunitv assianment ruleonen-recentlv-undated+ → alDo not ianore vHighlight words x 13 B ?1aee7aad app/current versionprivate function importContact(ScrmData): ContactContante mav not havo accounteif (isset(ScrmDatal'Accounttd!1)) d/** @var Contact Scontact */Ccontant = Cthic-sconfia-scontantc()_sundatoßnfnonto/ftenm_nnowidon idt = Gonmhatalitdill Cdata).Sthis->handle0biectDeletion(Scontact, ScrmData):& console [PROD]* console (EUlCascadeInvestigate Foreign KeJira Ticket:3• rli zz May 13-21.3+0 •Give me the lira ticket and the descriptionJY-XXXXX: Fix foreign key violation when matching activities to deleted Salesforce opportunitiesWhen an opportunity is deleted in Salesforce and the local deletion job runs, activities that belonged to that opportunity areematched via the RematchActivity0nCrmObjectDetach listener. During this rematch, the system attempts to re-link thendleObjectDeletion (because IsDeleted = truel)The deleted opportunity's ID is then used in Activity: :updateActivityCrmData to set activity.opportunity_id,causina a foreian key violation because the opportunity no longer exists in the databaseThe FixAdded checks in importLead. importAccount. importOpportunity. and importContact methods to return null if theentty was deleted oy hand leudi ectbeletionuphoschas→nano levo ectvelecion sopportunaty, scruvata"if (Sopportunity->trashed)) {return null:return Sopportunity:Ask anything (&OL)A difterencesprivate function importContact(ScrmData): ?ContactSaccount = null;Contante mav not havo accounteif (isset(ScrmData['AccountId'])) {/** @var Contact Scontact *Ccontant = Cthic-sconfia-Scontactc()_Sundatoßnfnonto/ftenm_nnowidon idt = Cenmhatalitdill Cdata)Sthie, hondlonhdontholotionfchontont Canmhotol.Po 4 space....
|
NULL
|
-4777859963980972455
|
NULL
|
click
|
ocr
|
NULL
|
PnostormFV faVsco.js°9 JY-20963-fix-irProiect• m Z PnostormFV faVsco.js°9 JY-20963-fix-irProiect• m Zoom• M ZoomBotcoomrnonec ActivitvCrmFieldsResolverlest.ono© ActivityProviderClientTest.php©) ActivityProviderRegistryTest.php© ActivityProviderServiceTest.php© CallDenormalizerRegistryTest.phpg crmownerkesolverlest.onpc) Meeuingbotserviceonmeeuingstaneoc MeeuingbotserviceonsharingData lesc) Meeungbotservicekequestkecoraingo) PanicipantConsencservicelesc.onoe) Panicioanisservicelest.onoc) ResponseValidationTraitTest.onoc) TrackRecordingFileSizeServiceTest.or150 V5c) TrackRecordingSizeEntorcerTest.onoc)ValidateEmitProspectEventTraitTest.r199 % >ActivitvProviders•Avatar225 vsN CalendanN Conference254V0 CrmM Bullhorn201 VCloseM Conner• M CrmOhiectsn Helpers• D HubspotD IntegrationApp403 %• hlictenercPipedrive435 %• MSalesforce> C Fields> 0 OpportunityMatcher478 YSLocal changesConcole yLog Xv Chandes A filles=env.local annC.liminnvDebuaCommand.nhn ann/Console/Commandsphp logging.php config© Service.php app/Services/Crm/Salesforce• Unversioned Siles 9 filedC ServiceTest.php X PlaybookRepository.phpT DeleteObjectsTrait.php© DeleteOpportunityJob.php= custom.log= laravel.log« SF jiminny@localhost]4 HS_local [jiminny@localhost]• SoftDeletes.phpyMatchactvitycrmbata.ong© CrmActivityService.php© RecordSelector.phpo Acuivily.onptii crm_configurations [EU] X A console [STAGING]siohp14 432 H168 V1 V.28 A V1rowvOU+declare strict-cypes=l)namespace Tests Unit Services CrmChangelist: Changesv V MServices/Crm/Salesforce 1 fileAuthor:ozyosbeyad/use...v © Service.phpAmend commit> _ U config 1 fileSian-oft commitclass Servicelest extends Testcase= env.local1 modifieduse TestPrivatelethod:Commit checksCommit MessageJY-20963Update copvriahioublic function testfetchAndAs:Poformat codeoublic function testFetchAndAs.Rearrange codeOouimize impor'spublic function testFetchAndAs:v Diftpublic function testFetchAndAs:TJ<→Do not ignore4 omerenceso laee/aaoCurrent versionpublic function testFetchAndAs:$this->handle0bjectDeletion(Slead, $crmDatalesforce..Sthis->handle0bjectDeletion(Slead, ScrmDatd)pUbLic tunccion cesthacchexacypublic function testMatchlomas.netunn Slead.19971087 Mif (Slead->trashed0) {neturn null.1090public tunction testbulldlaskspublic tunction testhapurmubnereturn $lead;$this->importOpportunityCrmFieldData($1433public tunction testbetinstallnuolic tunction testsuncprotilCancecommit': "3m" "oppulonnortunitv assianment ruleonen-recentlv-undated+ → alDo not ianore vHighlight words x 13 B ?1aee7aad app/current versionprivate function importContact(ScrmData): ContactContante mav not havo accounteif (isset(ScrmDatal'Accounttd!1)) d/** @var Contact Scontact */Ccontant = Cthic-sconfia-scontantc()_sundatoßnfnonto/ftenm_nnowidon idt = Gonmhatalitdill Cdata).Sthis->handle0biectDeletion(Scontact, ScrmData):& console [PROD]* console (EUlCascadeInvestigate Foreign KeJira Ticket:3• rli zz May 13-21.3+0 •Give me the lira ticket and the descriptionJY-XXXXX: Fix foreign key violation when matching activities to deleted Salesforce opportunitiesWhen an opportunity is deleted in Salesforce and the local deletion job runs, activities that belonged to that opportunity areematched via the RematchActivity0nCrmObjectDetach listener. During this rematch, the system attempts to re-link thendleObjectDeletion (because IsDeleted = truel)The deleted opportunity's ID is then used in Activity: :updateActivityCrmData to set activity.opportunity_id,causina a foreian key violation because the opportunity no longer exists in the databaseThe FixAdded checks in importLead. importAccount. importOpportunity. and importContact methods to return null if theentty was deleted oy hand leudi ectbeletionuphoschas→nano levo ectvelecion sopportunaty, scruvata"if (Sopportunity->trashed)) {return null:return Sopportunity:Ask anything (&OL)A difterencesprivate function importContact(ScrmData): ?ContactSaccount = null;Contante mav not havo accounteif (isset(ScrmData['AccountId'])) {/** @var Contact Scontact *Ccontant = Cthic-sconfia-Scontactc()_Sundatoßnfnonto/ftenm_nnowidon idt = Cenmhatalitdill Cdata)Sthie, hondlonhdontholotionfchontont Canmhotol.Po 4 space....
|
70657
|
NULL
|
NULL
|
NULL
|
|
70658
|
2536
|
23
|
2026-05-22T10:27:29.424822+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779445649424_m1.jpg...
|
Slack
|
platform-inner-team (Channel) - Jiminny Inc - 2 ne platform-inner-team (Channel) - Jiminny Inc - 2 new items - Slack...
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Switch workspaces… (Jiminny Inc) Has new messages
Switch workspaces… (Jiminny Inc) Has new messages
Home
Home
DMs
DMs
Activity
Activity
Files
Files
Later
Later...
|
[{"role":"AXPopUpButton","text [{"role":"AXPopUpButton","text":"Switch workspaces… (Jiminny Inc) Has new messages","depth":14,"bounds":{"left":0.51180553,"top":0.08111111,"width":0.025,"height":0.04},"on_screen":true,"role_description":"pop-up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXRadioButton","text":"Home","depth":14,"bounds":{"left":0.50625,"top":0.14,"width":0.036111113,"height":0.075555556},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":true,"is_expanded":false},{"role":"AXStaticText","text":"Home","depth":16,"bounds":{"left":0.5138889,"top":0.19222222,"width":0.020833334,"height":0.015555556},"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"DMs","depth":14,"bounds":{"left":0.50625,"top":0.21555555,"width":0.036111113,"height":0.075555556},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"DMs","depth":16,"bounds":{"left":0.5159722,"top":0.26777777,"width":0.016666668,"height":0.015555556},"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"Activity","depth":14,"bounds":{"left":0.50625,"top":0.2911111,"width":0.036111113,"height":0.075555556},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Activity","depth":16,"bounds":{"left":0.51111114,"top":0.34333333,"width":0.027083334,"height":0.015555556},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.51111114,"top":0.34333333,"width":0.0055555557,"height":0.015555556}},{"char_start":1,"char_count":7,"bounds":{"left":0.5159722,"top":0.34333333,"width":0.022222223,"height":0.015555556}}],"role_description":"text"},{"role":"AXRadioButton","text":"Files","depth":14,"bounds":{"left":0.50625,"top":0.36666667,"width":0.036111113,"height":0.075555556},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Files","depth":16,"bounds":{"left":0.51666665,"top":0.4188889,"width":0.015972223,"height":0.015555556},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.51666665,"top":0.4188889,"width":0.004166667,"height":0.015555556}},{"char_start":1,"char_count":4,"bounds":{"left":0.5208333,"top":0.4188889,"width":0.011805556,"height":0.015555556}}],"role_description":"text"},{"role":"AXRadioButton","text":"Later","depth":14,"bounds":{"left":0.50625,"top":0.4422222,"width":0.036111113,"height":0.075555556},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Later","depth":16,"bounds":{"left":0.5152778,"top":0.49444443,"width":0.018055556,"height":0.015555556},"on_screen":true,"role_description":"text"}]...
|
-2917331277012372565
|
-5815000689293182809
|
visual_change
|
hybrid
|
NULL
|
Switch workspaces… (Jiminny Inc) Has new messages
Switch workspaces… (Jiminny Inc) Has new messages
Home
Home
DMs
DMs
Activity
Activity
Files
Files
Later
Later
SlackFileEditViewGoHistoryWindowHelpDOCKERDEV (-zsh)O $82APP (-zsh)tests/Unit/Services/Mail/0ffice/EmailApiClientTest.phptests/Unit/Services/Mail/TextRelayServiceTest.phptests/Unit/Services/RecallAI/RecallAIServiceTest.php79 files changed, 4254 insertions(+), 2020 deletions(-)create mode 100644•github/claude-reviewer/no-ticket-warning.txtcreate mode.github/claude-reviewer/scripts/fetch-jira-context.mjsdelete mode100644 app/Component/ES/Processor/BatchStatusInterface.phpcreate mode100644 app/Component/ES/Processor/RedisEntityExtractor.phpdelete mode100644 app/Component/ES/Processor/Traits/WorkerIdTrait.php100644 app/Component/ES/QueuePriorityEnum.phpcreate mode100644 front-end/src/components/DealInsights/Modals/__tests__/AskAnythingModacreate mode 100644 front-end/src/components/shared/AskAnything/__tests__/__snapshots__/Asdelete mode100644 front-end/src/components/shared/AskAnything/__tests__/_snapshots_./Ascreate mode 100644 tests/Unit/Component/ES/Processor/RedisEntityExtractorTest.phpdelete mode 100644 tests/Unit/Component/ES/Processor/Traits/WorkerIdTraitTest.phpcreate mode100644 tests/Unit/Component/ES/QueuePriorityEnumTest.phpcreate mode 100644 tests/Unit/Notifications/Activities/SmsReceivedTest.phpcreate mode100644 tests/Unit/Services/Mail/TextRelayServiceTest.phplukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ co -b JY-20963-fix-imporSwitched to a new branch 'JY-20963-fix-import-on-deleted-entity'lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/Jiminny/app (JY-20963-fix-import-on-deleted-ent|docker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix--config=.php-cs-fixer.dist.phWhat's next:Try Docker Debug forseamless, persistent debugging tools in any container or image →Learn moreat [URL_WITH_CREDENTIALS] ~/jiminny/app (JY-20963-fix-import-on-deleted-ent|docker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.phPHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminskiandcontributors.PHP runtime: 8.3.30Running analysis on 7 cores with 10 files per process.Parallel runner is an experimental feature and may be unstable, use it at your own risk.Loaded config default from".php-cs-fixer.dist.php".5691/5691 [8100%Fixed 0 of 5691 files in 96.077 seconds, 67.00 MB memory usedWhat's next:Try Docker Debug for seamless, persistent debugging tools in anycontainer orimage →Learn moreat https://docs.docker.com/go/debug-cli/lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20963-fix-import-on-deleted-e<APP (...ED→HomeDMsActivityFilesLater...More+Jiminny ...w Starrea& jiminny-x-integrati...8platform-inner-teamChannels# ai-chapter# alerts# backend# bugs# confusion-clinic# curiosity_lab# engineering# general# happy_birthday# jiminny-bg# platform-tickets# product_launches# random# releases# sofia-office# support# thank-yous# the_people_of_jimi...• Direct messages. Galya Dimitrova "o Nikolay YankovR. Aneliya Angelova. Stoyan TanevIomac GrshamQlahl100% C8•Fri 22 May 13:27:29Describe what you are looking forJY-20813 Jira CloudXTwilio number is not released whenVoice role is removed from userJira Cloud CDetailsConversations 1DescriptionPrecondition:User "A" has Recorder & Voice * * role and has anassigned Twilio softphone number.Steps to reproduce:1. Log in to Jiminny as an admin user.2. Navigate to Organization Settings > User page/settings/organization/members3. Remove the Voice role from user "A"4. Check the assigned number in TwilioExpected:The assigned phone number should be automaticallyreleased in Twilio.Observed:The phone number is not released in Twilio.*Note: * The issue appears to be that the number isremoved locally but not released in Twilio.• users.softphone_number is set to NULL ==>CORRECT• the related record in phone_numbers table is deletnd==> CORRECTStatusOpen in Jirait Summarize...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
70657
|
2537
|
20
|
2026-05-22T10:27:26.707766+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779445646707_m2.jpg...
|
Slack
|
platform-inner-team (Channel) - Jiminny Inc - 2 ne platform-inner-team (Channel) - Jiminny Inc - 2 new items - Slack...
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Switch workspaces… (Jiminny Inc) Has new messages
Switch workspaces… (Jiminny Inc) Has new messages
Home
Home
DMs
DMs
Activity
Activity
Files
Files
Later
Later
More…
More
Unreads
Threads
Huddles
Drafts & sent
1
Directories
jiminny-x-integration-app
platform-inner-team
ai-chapter
alerts
backend
bugs
confusion-clinic
curiosity_lab
engineering
general
happy_birthday
jiminny-bg
platform-tickets
product_launches
random
releases
sofia-office
support
thank-yous
the_people_of_jiminny
Galya Dimitrova
Nikolay Yankov
Aneliya Angelova
Stoyan Tanev
James Graham
Stefka Stoyanova
Vasil Vasilev
Stoyan Tomov
Todor Stamatov
Lukas Kovalik
you
Jira Cloud
Toast
Twilio number is not released when Voice role is removed from user
Twilio number is not released when Voice role is removed from user
Open in Jira Cloud
Jira Cloud
Details
Conversations 1
Creator
Example Creator
Updated
Updated Date
Description
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
Sed do eiusmod temporincididunt ut labore et dolore
magna aliqua. Ut enim ad minim veniam, quis nostrud
exercitation ullamco laboris nisi ut aliquip ex ea
commodo consequat.
Duis aute irure dolor in reprehenderit in voluptate velit
esse cillum dolore eu fugiat nulla pariatur.
Excepteur sint occaecat cupidatat non proident, sunt in culpa qui
officia deserunt mollit anim id est laborum.
Processing uploaded file… complete! Message ready to be sent.
loading…...
|
[{"role":"AXPopUpButton","text [{"role":"AXPopUpButton","text":"Switch workspaces… (Jiminny Inc) Has new messages","depth":14,"bounds":{"left":0.5152925,"top":1.0,"width":0.011968086,"height":-0.058260202},"on_screen":true,"role_description":"pop-up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXRadioButton","text":"Home","depth":14,"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":true,"is_expanded":false},{"role":"AXStaticText","text":"Home","depth":16,"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"DMs","depth":14,"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"DMs","depth":16,"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"Activity","depth":14,"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Activity","depth":16,"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"Files","depth":14,"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Files","depth":16,"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"Later","depth":14,"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Later","depth":16,"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"More…","depth":14,"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"More","depth":16,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Unreads","depth":21,"bounds":{"left":0.5465425,"top":1.0,"width":0.018949468,"height":-0.09177971},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Threads","depth":21,"bounds":{"left":0.5465425,"top":1.0,"width":0.01761968,"height":-0.09177971},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Huddles","depth":21,"bounds":{"left":0.5465425,"top":1.0,"width":0.018284574,"height":-0.09177971},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Drafts & sent","depth":21,"bounds":{"left":0.5465425,"top":1.0,"width":0.02925532,"height":-0.09177971},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":21,"bounds":{"left":0.5980718,"top":1.0,"width":0.0026595744,"height":-0.09177971},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Directories","depth":21,"bounds":{"left":0.5465425,"top":1.0,"width":0.024268618,"height":-0.09177971},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"jiminny-x-integration-app","depth":23,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"platform-inner-team","depth":23,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ai-chapter","depth":23,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"alerts","depth":23,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"backend","depth":23,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"bugs","depth":23,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"confusion-clinic","depth":23,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"curiosity_lab","depth":23,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"engineering","depth":23,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"general","depth":23,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"happy_birthday","depth":23,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"jiminny-bg","depth":23,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"platform-tickets","depth":23,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"product_launches","depth":23,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"random","depth":23,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"releases","depth":23,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"sofia-office","depth":23,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"support","depth":23,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"thank-yous","depth":23,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"the_people_of_jiminny","depth":23,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Galya Dimitrova","depth":23,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Nikolay Yankov","depth":23,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Aneliya Angelova","depth":23,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Stoyan Tanev","depth":23,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"James Graham","depth":23,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Stefka Stoyanova","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Vasil Vasilev","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Stoyan Tomov","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Todor Stamatov","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Lukas Kovalik","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"you","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Jira Cloud","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Toast","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXHeading","text":"Twilio number is not released when Voice role is removed from user","depth":18,"on_screen":true,"role_description":"heading"},{"role":"AXStaticText","text":"Twilio number is not released when Voice role is removed from user","depth":19,"on_screen":true,"role_description":"text"},{"role":"AXLink","text":"Open in Jira Cloud","depth":18,"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Jira Cloud","depth":19,"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"Details","depth":19,"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":true,"is_expanded":false},{"role":"AXRadioButton","text":"Conversations 1","depth":19,"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Creator","depth":20,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Example Creator","depth":20,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Updated","depth":20,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Updated Date","depth":20,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Description","depth":20,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Lorem ipsum dolor sit amet, consectetur adipiscing elit.","depth":19,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Sed do eiusmod temporincididunt ut labore et dolore","depth":19,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"magna aliqua. Ut enim ad minim veniam, quis nostrud","depth":19,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"exercitation ullamco laboris nisi ut aliquip ex ea","depth":19,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"commodo consequat.","depth":19,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Duis aute irure dolor in reprehenderit in voluptate velit","depth":19,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"esse cillum dolore eu fugiat nulla pariatur.","depth":19,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Excepteur sint occaecat cupidatat non proident, sunt in culpa qui","depth":19,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"officia deserunt mollit anim id est laborum.","depth":19,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Processing uploaded file… complete! Message ready to be sent.","depth":11,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"loading…","depth":11,"on_screen":false,"role_description":"text"}]...
|
4667658736668554293
|
4701358721430658631
|
click
|
hybrid
|
NULL
|
Switch workspaces… (Jiminny Inc) Has new messages
Switch workspaces… (Jiminny Inc) Has new messages
Home
Home
DMs
DMs
Activity
Activity
Files
Files
Later
Later
More…
More
Unreads
Threads
Huddles
Drafts & sent
1
Directories
jiminny-x-integration-app
platform-inner-team
ai-chapter
alerts
backend
bugs
confusion-clinic
curiosity_lab
engineering
general
happy_birthday
jiminny-bg
platform-tickets
product_launches
random
releases
sofia-office
support
thank-yous
the_people_of_jiminny
Galya Dimitrova
Nikolay Yankov
Aneliya Angelova
Stoyan Tanev
James Graham
Stefka Stoyanova
Vasil Vasilev
Stoyan Tomov
Todor Stamatov
Lukas Kovalik
you
Jira Cloud
Toast
Twilio number is not released when Voice role is removed from user
Twilio number is not released when Voice role is removed from user
Open in Jira Cloud
Jira Cloud
Details
Conversations 1
Creator
Example Creator
Updated
Updated Date
Description
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
Sed do eiusmod temporincididunt ut labore et dolore
magna aliqua. Ut enim ad minim veniam, quis nostrud
exercitation ullamco laboris nisi ut aliquip ex ea
commodo consequat.
Duis aute irure dolor in reprehenderit in voluptate velit
esse cillum dolore eu fugiat nulla pariatur.
Excepteur sint occaecat cupidatat non proident, sunt in culpa qui
officia deserunt mollit anim id est laborum.
Processing uploaded file… complete! Message ready to be sent.
loading…
PnostormFV faVsco.js°9 JY-20963-fix-irProiect v• m Zoom• M ZoomBotcoomrnonec ActivitvCrmFieldsResolverlest.ono© ActivityProviderClientTest.php©) ActivityProviderRegistryTest.php© ActivityProviderServiceTest.php© CallDenormalizerRegistryTest.phpg crmownerkesolverlest.onpc) Meeuingbotserviceonmeeuingstaneoc MeeuingbotserviceonsharingData lesc) Meeungbotservicekequestkecoraingo) PanicipantConsencservicelesc.onoe) Panicioanisservicelest.onoc) ResponseValidationTraitTest.onoc) TrackRecordingFileSizeServiceTest.or150 V5c) TrackRecordingSizeEntorcerTest.onoc)ValidateEmitProspectEventTraitTest.r199 % >ActivitvProviders•Avatar225 vsN CalendanN Conference254VD CrmM Bullhorn201 VCloseM Conner• M CrmOhiectsn Helpers2012• D HubspotD IntegrationApp403 %• hlictenercPipedrive435 %• MSalesforce> C Fields> 0 OpportunityMatcher478 YSLocal changesConcole yLog Xv Chandes A filles=env.local annC.liminnvDebuaCommand.nhn ann/Console/Commandsphp logging.php config© Service.php app/Services/Crm/Salesforce• Unversioned Siles 9 filedC ServiceTest.php X PlaybookRepository.phpT DeleteObjectsTrait.php© DeleteOpportunityJob.php= custom.log= laravel.log« SF jiminny@localhost]4 HS_local [jiminny@localhost]• SoftDeletes.phpyMatchactvitycrmbata.ong© CrmActivityService.php© RecordSelector.phpo Acuivily.onptii crm_configurations [EU] X A console [STAGING]siohp14 432 H168 V1 V.28 A V1rowvOU+declare strict-cypes=l)namespace Tests Unit Services CrmChangelist: Changesv V MServices/Crm/Salesforce 1 fileAuthor:ozyosbeyad/use...class Servicelest extends Testcasev © Service.php> _ U config 1 file= env.localAmend commitSian-oft commit1 modifieduse TestPrivatelethod:Commit checksCommit MessageJY-20963Update copvriahioublic function testfetchAndAs:Poformat codeoublic function testFetchAndAs.Rearrange codeOouimize impor'spublic function testFetchAndAs:v Diftpublic function testFetchAndAs:TJ<→Do not ignore4 omerenceso laee/aaoCurrent versionpublic function testFetchAndAs:$this->handle0bjectDeletion(Slead, $crmDatalesforce..Sthis->handle0bjectDeletion(Slead, ScrmDatd)pUbLic tunccion cesthacchexacypublic function testMatchlomas.netunn Slead.19971087 Mif (Slead->trashed0) {neturn null.1090public tunction testbulldlaskspublic tunction testhapurmubnereturn $lead;$this->importOpportunityCrmFieldData($1433public tunction testbetinstallnuolic tunction testsuncprotilCancecommit': "3m" "oppulonnortunitv assianment ruleonen-recentlv-undated+ → alDo not ianore vHighlight words x 13 B ?1aee7aad app/current versionprivate function importContact(ScrmData): ContactContante mav not havo accounteif (isset(ScrmDatal'Accounttd!1)) d/** @var Contact Scontact */Ccontant = Cthic-sconfia-scontantc()_sundatoßnfnonto/ftenm_nnowidon idt = Gonmhatalitdill Cdata).Sthis->handle0biectDeletion(Scontact, ScrmData):& console [PROD]* console (EUlCascadeInvestigate Foreign KeJira Ticket:3• rli zz May 13-21•20+O •Give me the lira ticket and the descriptionJY-XXXXX: Fix foreign key violation when matching activities to deleted Salesforce opportunitiesWhen an opportunity is deleted in Salesforce and the local deletion job runs, activities that belonged to that opportunity areematched via the RematchActivity0nCrmObjectDetach listener. During this rematch, the system attempts to re-link thendleObjectDeletion (because IsDeleted = truel)The deleted opportunity's ID is then used in Activity: :updateActivityCrmData to set activity.opportunity_id,causina a foreian key violation because the opportunity no longer exists in the databaseThe FixAdded checks in importLead. importAccount. importOpportunity. and importContact methods to return null if theentty was deleted oy hand leudi ectbeletionuphoschas→nano levo ectvelecion sopportunaty, scruvata"if (Sopportunity->trashed)) {return null:return Sopportunity:Ask anything (&OL)A difterencesprivate function importContact(ScrmData): ?ContactSaccount = null;Contante mav not havo accounteif (isset(ScrmData['AccountId'])) {/** @var Contact Scontact *Ccontant = Cthic-sconfia-Scontactc()_Sundatoßnfnonto/ftenm_nnowidon idt = Cenmhatalitdill Cdata)Sthie, hondlonhdontholotionfchontont Canmhotol.Po 4 space....
|
NULL
|
NULL
|
NULL
|
NULL
|
|
70656
|
2536
|
22
|
2026-05-22T10:27:26.286450+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779445646286_m1.jpg...
|
Slack
|
platform-inner-team (Channel) - Jiminny Inc - 2 ne platform-inner-team (Channel) - Jiminny Inc - 2 new items - Slack...
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Switch workspaces… (Jiminny Inc) Has new messages
Switch workspaces… (Jiminny Inc) Has new messages
Home
Home
DMs
DMs
Activity
Activity
Files
Files
Later
Later
More…
More
Unreads
Threads
Huddles
Drafts & sent
1
Directories
jiminny-x-integration-app
platform-inner-team
ai-chapter
alerts...
|
[{"role":"AXPopUpButton","text [{"role":"AXPopUpButton","text":"Switch workspaces… (Jiminny Inc) Has new messages","depth":14,"bounds":{"left":0.51180553,"top":0.08111111,"width":0.025,"height":0.04},"on_screen":true,"role_description":"pop-up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXRadioButton","text":"Home","depth":14,"bounds":{"left":0.50625,"top":0.14,"width":0.036111113,"height":0.075555556},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":true,"is_expanded":false},{"role":"AXStaticText","text":"Home","depth":16,"bounds":{"left":0.5138889,"top":0.19222222,"width":0.020833334,"height":0.015555556},"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"DMs","depth":14,"bounds":{"left":0.50625,"top":0.21555555,"width":0.036111113,"height":0.075555556},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"DMs","depth":16,"bounds":{"left":0.5159722,"top":0.26777777,"width":0.016666668,"height":0.015555556},"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"Activity","depth":14,"bounds":{"left":0.50625,"top":0.2911111,"width":0.036111113,"height":0.075555556},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Activity","depth":16,"bounds":{"left":0.51111114,"top":0.34333333,"width":0.027083334,"height":0.015555556},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.51111114,"top":0.34333333,"width":0.0055555557,"height":0.015555556}},{"char_start":1,"char_count":7,"bounds":{"left":0.5159722,"top":0.34333333,"width":0.022222223,"height":0.015555556}}],"role_description":"text"},{"role":"AXRadioButton","text":"Files","depth":14,"bounds":{"left":0.50625,"top":0.36666667,"width":0.036111113,"height":0.075555556},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Files","depth":16,"bounds":{"left":0.51666665,"top":0.4188889,"width":0.015972223,"height":0.015555556},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.51666665,"top":0.4188889,"width":0.004166667,"height":0.015555556}},{"char_start":1,"char_count":4,"bounds":{"left":0.5208333,"top":0.4188889,"width":0.011805556,"height":0.015555556}}],"role_description":"text"},{"role":"AXRadioButton","text":"Later","depth":14,"bounds":{"left":0.50625,"top":0.4422222,"width":0.036111113,"height":0.075555556},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Later","depth":16,"bounds":{"left":0.5152778,"top":0.49444443,"width":0.018055556,"height":0.015555556},"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"More…","depth":14,"bounds":{"left":0.50625,"top":0.5177778,"width":0.036111113,"height":0.075555556},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"More","depth":16,"bounds":{"left":0.5152778,"top":0.57,"width":0.01875,"height":0.015555556},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Unreads","depth":21,"bounds":{"left":0.57708335,"top":0.12777779,"width":0.039583333,"height":0.0011111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Threads","depth":21,"bounds":{"left":0.57708335,"top":0.12777779,"width":0.036805555,"height":0.0011111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Huddles","depth":21,"bounds":{"left":0.57708335,"top":0.12777779,"width":0.038194444,"height":0.0011111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Drafts & sent","depth":21,"bounds":{"left":0.57708335,"top":0.12777779,"width":0.06111111,"height":0.0011111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":21,"bounds":{"left":0.68472224,"top":0.12777779,"width":0.0055555557,"height":0.0011111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Directories","depth":21,"bounds":{"left":0.57708335,"top":0.12777779,"width":0.050694443,"height":0.0011111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"jiminny-x-integration-app","depth":23,"bounds":{"left":0.58819443,"top":0.15111111,"width":0.09166667,"height":0.02},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.58819443,"top":0.15111111,"width":0.0027777778,"height":0.02}},{"char_start":1,"char_count":24,"bounds":{"left":0.59097224,"top":0.15111111,"width":0.11319444,"height":0.02}}],"role_description":"text"},{"role":"AXStaticText","text":"platform-inner-team","depth":23,"bounds":{"left":0.58819443,"top":0.18222222,"width":0.093055554,"height":0.02},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ai-chapter","depth":23,"bounds":{"left":0.58819443,"top":0.25555557,"width":0.046527777,"height":0.02},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"alerts","depth":23,"bounds":{"left":0.58819443,"top":0.28666666,"width":0.025694445,"height":0.02},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.58819443,"top":0.28666666,"width":0.0055555557,"height":0.02}},{"char_start":1,"char_count":5,"bounds":{"left":0.59375,"top":0.28666666,"width":0.019444445,"height":0.02}}],"role_description":"text"}]...
|
-2721943170458547185
|
-8120917382953222401
|
visual_change
|
hybrid
|
NULL
|
Switch workspaces… (Jiminny Inc) Has new messages
Switch workspaces… (Jiminny Inc) Has new messages
Home
Home
DMs
DMs
Activity
Activity
Files
Files
Later
Later
More…
More
Unreads
Threads
Huddles
Drafts & sent
1
Directories
jiminny-x-integration-app
platform-inner-team
ai-chapter
alerts
SlackFileEditViewGoHistoryWindowHelp<DOCKERDEV (-zsh)О 882APP (-zsh)tests/Unit/Services/Mail/0ffice/EmailApiClientTest.phptests/Unit/Services/Mail/TextRelayServiceTest.phptests/Unit/Services/RecallAI/RecallAIServiceTest.php79 files changed, 4254 insertions(+), 2020 deletions(-)create mode 100644•github/claude-reviewer/no-ticket-warning.txtcreate mode100644.github/claude-reviewer/scripts/fetch-jira-context.mjsdelete mode100644 app/Component/ES/Processor/BatchStatusInterface.phpcreate mode100644 app/Component/ES/Processor/RedisEntityExtractor.phpdelete mode100644 app/Component/ES/Processor/Traits/WorkerIdTrait.phpcreate mode100644 app/Component/ES/QueuePriorityEnum.phpcreate mode100644 front-end/src/components/DealInsights/Modals/__tests__/AskAnythingModacreate mode 100644 front-end/src/components/shared/AskAnything/__tests__/__snapshots__/Asdelete mode100644 front-end/src/components/shared/AskAnything/__tests__/_snapshots_./Ascreate mode 100644 tests/Unit/Component/ES/Processor/RedisEntityExtractorTest.phpdelete mode 100644 tests/Unit/Component/ES/Processor/Traits/WorkerIdTraitTest.phpcreate mode100644 tests/Unit/Component/ES/QueuePriorityEnumTest.phpcreate mode 100644 tests/Unit/Notifications/Activities/SmsReceivedTest.phpcreate mode100644 tests/Unit/Services/Mail/TextRelayServiceTest.phplukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ co -b JY-20963-fix-imporSwitched to a new branch 'JY-20963-fix-import-on-deleted-entity'lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/Jiminny/app (JY-20963-fix-import-on-deleted-ent|docker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix--config=.php-cs-fixer.dist.phWhat's next:Try Docker Debug forseamless, persistent debugging tools in any container or image →Learn moreat [URL_WITH_CREDENTIALS] ~/jiminny/app (JY-20963-fix-import-on-deleted-ent|docker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.phPHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminskiandcontributors.PHP runtime: 8.3.30Running analysis on 7 cores with 10 files per process.Parallel runner is an experimental feature and may be unstable, use it at your own risk.Loaded config default from".php-cs-fixer.dist.php".5691/5691 [8100%Fixed 0 of 5691 files in 96.077 seconds, 67.00 MB memory usedWhat's next:Try Docker Debug for seamless, persistent debugging tools in anycontainer orimage →Learn moreat https://docs.docker.com/go/debug-cli/lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20963-fix-import-on-deleted-eAPP (...ED→HomeDMsActivityFilesLater...More+Jiminny ...w Starrea& jiminny-x-integrati...8platform-inner-teamChannels# ai-chapter# alerts# backend# bugs# confusion-clinic# curiosity_lab# engineering# general# happy_birthday# jiminny-bg# platform-tickets# product_launches# random# releases# sofia-office# support# thank-yous# the_people_of_jimi...• Direct messages. Galya Dimitrova "o Nikolay YankovR. Aneliya Angelova. Stoyan TanevIomac GrshamQlahl100% <478•Fri 22 May 13:27:25Describe what you are looking for<Bug JY-208... Jira Clo...S Syncing...Twilio number is not released whenVoice role is removed from userJira Cloud CDetailsConversations 1X...
|
70655
|
NULL
|
NULL
|
NULL
|
|
70655
|
2536
|
21
|
2026-05-22T10:27:24.429226+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779445644429_m1.jpg...
|
Slack
|
platform-inner-team (Channel) - Jiminny Inc - 2 ne platform-inner-team (Channel) - Jiminny Inc - 2 new items - Slack...
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Switch workspaces… (Jiminny Inc) Has new messages
Switch workspaces… (Jiminny Inc) Has new messages
Home
Home
DMs
DMs
Activity
Activity
Files
Files
Later
Later
More…
More
Unreads
Threads
Huddles
Drafts & sent
1
Directories
jiminny-x-integration-app
platform-inner-team
ai-chapter
alerts
backend
bugs
confusion-clinic
curiosity_lab
engineering
general
happy_birthday
jiminny-bg
platform-tickets
product_launches
random
releases
sofia-office
support
thank-yous
the_people_of_jiminny
Galya Dimitrova
Nikolay Yankov
Aneliya Angelova
Stoyan Tanev
James Graham
Stefka Stoyanova
Vasil Vasilev
Stoyan Tomov
Todor Stamatov
Lukas Kovalik
you
Jira Cloud
Toast
Messages
Messages
Channel Overview
Channel Overview
More
Add and Edit Channel Tabs
Canvas
List
Folder
Jump to date
Lukas Kovalik
Yesterday at 9:44:16 AM
9:44 AM
добро утро, тази страница не трябва ли да я показваме само ако няма reports
CleanShot 2026-05-21 at [EMAIL]
Toggle file
CleanShot 2026-05-21 at [EMAIL]
Download CleanShot 2026-05-21 at [EMAIL]
Share file: CleanShot 2026-05-21 at [EMAIL]
View canvas details
More actions
4 replies
Last reply 1 day ago
View thread
Steliyan Georgiev
Yesterday at 3:05:08 PM
3:05 PM
Може ли лайк?
https://github.com/jiminny/prophet/pull/519
https://github.com/jiminny/prophet/pull/519
"This is ready to merge from a code quality standpoint."
Nikolay Yankov
Yesterday at 5:32:49 PM
5:32 PM
може ли 1 approve, че не мога да го тествам без да merge в мастер
https://github.com/jiminny/app/pull/12115
https://github.com/jiminny/app/pull/12115
Yesterday at 5:32:57 PM
5:32
за github action-a
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Jump to date
New
Galya Dimitrova
Today at 1:24:02 PM
1:24 PM
Сторита за рефайнмънт за вторник:
https://jiminny.atlassian.net/browse/JY-20834
https://jiminny.atlassian.net/browse/JY-20834
- това го гледахме и преди но вкарах дизайн и по подробно описание и е добре пак да го минем
https://jiminny.atlassian.net/browse/JY-20836
https://jiminny.atlassian.net/browse/JY-20836
https://jiminny.atlassian.net/browse/JY-20813
https://jiminny.atlassian.net/browse/JY-20813
https://jiminny.atlassian.net/browse/JY-20814
https://jiminny.atlassian.net/browse/JY-20814
4 attachments
4 attachments
Jira Cloud Story JY-20834 MCP > Authenticate each user for easy access Story JY-20834 in Jira Cloud Preview in Slack
MCP > Authenticate each user for easy access
Story JY-20834 in Jira Cloud
Preview in Slack
Open in browser
Share Story JY-20834
View conversations
More actions
Jira Cloud Story JY-20836 MCP > Audit log Story JY-20836 in Jira Cloud Preview in Slack
MCP > Audit log
Story JY-20836 in Jira Cloud
Preview in Slack
Open in browser
Share Story JY-20836
View conversations
More actions
Jira Cloud Bug JY-20813 Twilio number is not released when Voice role is removed from user Bug JY-20813 in Jira Cloud Preview in Slack
Twilio number is not released when Voice role is removed from user
Bug JY-20813 in Jira Cloud
Preview in Slack
Open in browser
Share Bug JY-20813
View conversations
More actions
Jira Cloud Story JY-20814 Release unused Twilio numbers on Production Story JY-20814 in Jira Cloud Preview in Slack
Release unused Twilio numbers on Production
Story JY-20814 in Jira Cloud
Preview in Slack
Open in browser
Share Story JY-20814
View conversations
More actions
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Processing uploaded file… complete! Message ready to be sent.
Channel platform-inner-team
Twilio number is not released when Voice role is removed from user...
|
[{"role":"AXPopUpButton","text [{"role":"AXPopUpButton","text":"Switch workspaces… (Jiminny Inc) Has new messages","depth":14,"bounds":{"left":0.51180553,"top":0.08111111,"width":0.025,"height":0.04},"on_screen":true,"role_description":"pop-up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXRadioButton","text":"Home","depth":14,"bounds":{"left":0.50625,"top":0.14,"width":0.036111113,"height":0.075555556},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":true,"is_expanded":false},{"role":"AXStaticText","text":"Home","depth":16,"bounds":{"left":0.5138889,"top":0.19222222,"width":0.020833334,"height":0.015555556},"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"DMs","depth":14,"bounds":{"left":0.50625,"top":0.21555555,"width":0.036111113,"height":0.075555556},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"DMs","depth":16,"bounds":{"left":0.5159722,"top":0.26777777,"width":0.016666668,"height":0.015555556},"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"Activity","depth":14,"bounds":{"left":0.50625,"top":0.2911111,"width":0.036111113,"height":0.075555556},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Activity","depth":16,"bounds":{"left":0.51111114,"top":0.34333333,"width":0.027083334,"height":0.015555556},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.51111114,"top":0.34333333,"width":0.0055555557,"height":0.015555556}},{"char_start":1,"char_count":7,"bounds":{"left":0.5159722,"top":0.34333333,"width":0.022222223,"height":0.015555556}}],"role_description":"text"},{"role":"AXRadioButton","text":"Files","depth":14,"bounds":{"left":0.50625,"top":0.36666667,"width":0.036111113,"height":0.075555556},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Files","depth":16,"bounds":{"left":0.51666665,"top":0.4188889,"width":0.015972223,"height":0.015555556},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.51666665,"top":0.4188889,"width":0.004166667,"height":0.015555556}},{"char_start":1,"char_count":4,"bounds":{"left":0.5208333,"top":0.4188889,"width":0.011805556,"height":0.015555556}}],"role_description":"text"},{"role":"AXRadioButton","text":"Later","depth":14,"bounds":{"left":0.50625,"top":0.4422222,"width":0.036111113,"height":0.075555556},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Later","depth":16,"bounds":{"left":0.5152778,"top":0.49444443,"width":0.018055556,"height":0.015555556},"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"More…","depth":14,"bounds":{"left":0.50625,"top":0.5177778,"width":0.036111113,"height":0.075555556},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"More","depth":16,"bounds":{"left":0.5152778,"top":0.57,"width":0.01875,"height":0.015555556},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Unreads","depth":21,"bounds":{"left":0.57708335,"top":0.12777779,"width":0.039583333,"height":0.0011111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Threads","depth":21,"bounds":{"left":0.57708335,"top":0.12777779,"width":0.036805555,"height":0.0011111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Huddles","depth":21,"bounds":{"left":0.57708335,"top":0.12777779,"width":0.038194444,"height":0.0011111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Drafts & sent","depth":21,"bounds":{"left":0.57708335,"top":0.12777779,"width":0.06111111,"height":0.0011111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":21,"bounds":{"left":0.68472224,"top":0.12777779,"width":0.0055555557,"height":0.0011111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Directories","depth":21,"bounds":{"left":0.57708335,"top":0.12777779,"width":0.050694443,"height":0.0011111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"jiminny-x-integration-app","depth":23,"bounds":{"left":0.58819443,"top":0.15111111,"width":0.09166667,"height":0.02},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.58819443,"top":0.15111111,"width":0.0027777778,"height":0.02}},{"char_start":1,"char_count":24,"bounds":{"left":0.59097224,"top":0.15111111,"width":0.11319444,"height":0.02}}],"role_description":"text"},{"role":"AXStaticText","text":"platform-inner-team","depth":23,"bounds":{"left":0.58819443,"top":0.18222222,"width":0.093055554,"height":0.02},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ai-chapter","depth":23,"bounds":{"left":0.58819443,"top":0.25555557,"width":0.046527777,"height":0.02},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"alerts","depth":23,"bounds":{"left":0.58819443,"top":0.28666666,"width":0.025694445,"height":0.02},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.58819443,"top":0.28666666,"width":0.0055555557,"height":0.02}},{"char_start":1,"char_count":5,"bounds":{"left":0.59375,"top":0.28666666,"width":0.019444445,"height":0.02}}],"role_description":"text"},{"role":"AXStaticText","text":"backend","depth":23,"bounds":{"left":0.58819443,"top":0.31777778,"width":0.038194444,"height":0.02},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"bugs","depth":23,"bounds":{"left":0.58819443,"top":0.34888887,"width":0.022222223,"height":0.02},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"confusion-clinic","depth":23,"bounds":{"left":0.58819443,"top":0.38,"width":0.072222225,"height":0.02},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.58819443,"top":0.38,"width":0.0048611113,"height":0.02}},{"char_start":1,"char_count":15,"bounds":{"left":0.59305555,"top":0.38,"width":0.06736111,"height":0.02}}],"role_description":"text"},{"role":"AXStaticText","text":"curiosity_lab","depth":23,"bounds":{"left":0.58819443,"top":0.41111112,"width":0.057638887,"height":0.02},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.58819443,"top":0.41111112,"width":0.0048611113,"height":0.02}},{"char_start":1,"char_count":12,"bounds":{"left":0.59305555,"top":0.41111112,"width":0.05277778,"height":0.02}}],"role_description":"text"},{"role":"AXStaticText","text":"engineering","depth":23,"bounds":{"left":0.58819443,"top":0.4422222,"width":0.054166667,"height":0.02},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"general","depth":23,"bounds":{"left":0.58819443,"top":0.47333333,"width":0.034027778,"height":0.02},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"happy_birthday","depth":23,"bounds":{"left":0.58819443,"top":0.5044444,"width":0.07083333,"height":0.02},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"jiminny-bg","depth":23,"bounds":{"left":0.58819443,"top":0.53555554,"width":0.048611112,"height":0.02},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"platform-tickets","depth":23,"bounds":{"left":0.58819443,"top":0.56666666,"width":0.072916664,"height":0.02},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.58819443,"top":0.56666666,"width":0.00625,"height":0.02}},{"char_start":1,"char_count":15,"bounds":{"left":0.59444445,"top":0.56666666,"width":0.06666667,"height":0.02}}],"role_description":"text"},{"role":"AXStaticText","text":"product_launches","depth":23,"bounds":{"left":0.58819443,"top":0.5977778,"width":0.08055556,"height":0.02},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"random","depth":23,"bounds":{"left":0.58819443,"top":0.6288889,"width":0.035416666,"height":0.02},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"releases","depth":23,"bounds":{"left":0.58819443,"top":0.66,"width":0.038194444,"height":0.02},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"sofia-office","depth":23,"bounds":{"left":0.58819443,"top":0.6911111,"width":0.05138889,"height":0.02},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.58819443,"top":0.6911111,"width":0.0048611113,"height":0.02}},{"char_start":1,"char_count":11,"bounds":{"left":0.59305555,"top":0.6911111,"width":0.045833334,"height":0.02}}],"role_description":"text"},{"role":"AXStaticText","text":"support","depth":23,"bounds":{"left":0.58819443,"top":0.7222222,"width":0.036111113,"height":0.02},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"thank-yous","depth":23,"bounds":{"left":0.58819443,"top":0.75333333,"width":0.05138889,"height":0.02},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"the_people_of_jiminny","depth":23,"bounds":{"left":0.58819443,"top":0.78444445,"width":0.094444446,"height":0.02},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.58819443,"top":0.78444445,"width":0.004166667,"height":0.02}},{"char_start":1,"char_count":20,"bounds":{"left":0.5923611,"top":0.78444445,"width":0.09861111,"height":0.02}}],"role_description":"text"},{"role":"AXStaticText","text":"Galya Dimitrova","depth":23,"bounds":{"left":0.58819443,"top":0.8577778,"width":0.07361111,"height":0.02},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Nikolay Yankov","depth":23,"bounds":{"left":0.58819443,"top":0.8888889,"width":0.06875,"height":0.02},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Aneliya Angelova","depth":23,"bounds":{"left":0.58819443,"top":0.92,"width":0.07847222,"height":0.02},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Stoyan Tanev","depth":23,"bounds":{"left":0.58819443,"top":0.95111114,"width":0.060416665,"height":0.02},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"James Graham","depth":23,"bounds":{"left":0.58819443,"top":0.9822222,"width":0.06666667,"height":0.012222222},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Stefka Stoyanova","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Vasil Vasilev","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Stoyan Tomov","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Todor Stamatov","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Lukas Kovalik","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"you","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Jira Cloud","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Toast","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXRadioButton","text":"Messages","depth":18,"bounds":{"left":0.71319443,"top":0.12777779,"width":0.06458333,"height":0.04222222},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":true,"is_expanded":false},{"role":"AXStaticText","text":"Messages","depth":20,"bounds":{"left":0.7326389,"top":0.14,"width":0.039583333,"height":0.017777778},"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"Channel Overview","depth":18,"bounds":{"left":0.7798611,"top":0.12777779,"width":0.1,"height":0.04222222},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Channel Overview","depth":20,"bounds":{"left":0.79930556,"top":0.14,"width":0.075,"height":0.017777778},"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"More","depth":19,"bounds":{"left":0.8819444,"top":0.12777779,"width":0.04236111,"height":0.04222222},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXPopUpButton","text":"Add and Edit Channel Tabs","depth":18,"bounds":{"left":0.92430556,"top":0.12777779,"width":0.022222223,"height":0.04222222},"on_screen":true,"role_description":"pop-up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Canvas","depth":18,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"List","depth":18,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Folder","depth":18,"on_screen":false,"role_description":"text"},{"role":"AXPopUpButton","text":"Jump to date","depth":23,"bounds":{"left":0.81527776,"top":0.17666666,"width":0.068055555,"height":0.031111112},"on_screen":true,"role_description":"pop-up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Lukas Kovalik","depth":24,"bounds":{"left":0.7465278,"top":0.16111112,"width":0.06458333,"height":0.0011111111},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":24,"bounds":{"left":0.8111111,"top":0.16111112,"width":0.0055555557,"height":0.0011111111},"on_screen":true,"role_description":"text"},{"role":"AXLink","text":"Yesterday at 9:44:16 AM","depth":24,"bounds":{"left":0.81666666,"top":0.16111112,"width":0.031944446,"height":0.0011111111},"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"9:44 AM","depth":25,"bounds":{"left":0.81666666,"top":0.16111112,"width":0.031944446,"height":0.0011111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"добро утро, тази страница не трябва ли да я показваме само ако няма reports","depth":25,"bounds":{"left":0.7465278,"top":0.16111112,"width":0.21527778,"height":0.0011111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"CleanShot 2026-05-21 at 09.43.20@2x.png","depth":25,"bounds":{"left":0.7465278,"top":0.16111112,"width":0.1736111,"height":0.0011111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":25,"bounds":{"left":0.9201389,"top":0.16111112,"width":0.0027777778,"height":0.0011111111},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Toggle file","depth":25,"bounds":{"left":0.92291665,"top":0.16111112,"width":0.013888889,"height":0.0011111111},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":true},{"role":"AXLink","text":"CleanShot 2026-05-21 at 09.43.20@2x.png","depth":27,"bounds":{"left":0.7465278,"top":0.16111112,"width":0.2361111,"height":0.0011111111},"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"Download CleanShot 2026-05-21 at 09.43.20@2x.png","depth":28,"bounds":{"left":0.8840278,"top":0.16111112,"width":0.022222223,"height":0.0011111111},"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Share file: CleanShot 2026-05-21 at 09.43.20@2x.png","depth":28,"bounds":{"left":0.90625,"top":0.16111112,"width":0.022222223,"height":0.0011111111},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"View canvas details","depth":28,"bounds":{"left":0.9284722,"top":0.16111112,"width":0.022222223,"height":0.0011111111},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXPopUpButton","text":"More actions","depth":28,"bounds":{"left":0.95069444,"top":0.16111112,"width":0.022222223,"height":0.0011111111},"on_screen":true,"role_description":"pop-up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"4 replies","depth":24,"bounds":{"left":0.80694443,"top":0.16111112,"width":0.034027778,"height":0.0011111111},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Last reply 1 day ago","depth":25,"bounds":{"left":0.84652776,"top":0.16111112,"width":0.07847222,"height":0.0011111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"View thread","depth":25,"bounds":{"left":0.84652776,"top":0.16111112,"width":0.047916666,"height":0.0011111111},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Steliyan Georgiev","depth":24,"bounds":{"left":0.7465278,"top":0.16111112,"width":0.08263889,"height":0.0011111111},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":24,"bounds":{"left":0.82916665,"top":0.16111112,"width":0.0055555557,"height":0.0011111111},"on_screen":true,"role_description":"text"},{"role":"AXLink","text":"Yesterday at 3:05:08 PM","depth":24,"bounds":{"left":0.83402777,"top":0.16111112,"width":0.031944446,"height":0.0011111111},"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"3:05 PM","depth":25,"bounds":{"left":0.83402777,"top":0.16111112,"width":0.031944446,"height":0.0011111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Може ли лайк?","depth":25,"bounds":{"left":0.7465278,"top":0.16111112,"width":0.07361111,"height":0.0011111111},"on_screen":true,"role_description":"text"},{"role":"AXLink","text":"https://github.com/jiminny/prophet/pull/519","depth":25,"bounds":{"left":0.7465278,"top":0.16111112,"width":0.20555556,"height":0.0011111111},"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"https://github.com/jiminny/prophet/pull/519","depth":26,"bounds":{"left":0.7465278,"top":0.16111112,"width":0.20555556,"height":0.0011111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"\"This is ready to merge from a code quality standpoint.\"","depth":25,"bounds":{"left":0.7465278,"top":0.16111112,"width":0.23055555,"height":0.0011111111},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Nikolay Yankov","depth":24,"bounds":{"left":0.7465278,"top":0.16111112,"width":0.072222225,"height":0.0011111111},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":24,"bounds":{"left":0.81805557,"top":0.16111112,"width":0.00625,"height":0.0011111111},"on_screen":true,"role_description":"text"},{"role":"AXLink","text":"Yesterday at 5:32:49 PM","depth":24,"bounds":{"left":0.82361114,"top":0.16111112,"width":0.031944446,"height":0.0011111111},"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"5:32 PM","depth":25,"bounds":{"left":0.82361114,"top":0.16111112,"width":0.031944446,"height":0.0011111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"може ли 1 approve, че не мога да го тествам без да merge в мастер","depth":25,"bounds":{"left":0.7465278,"top":0.16111112,"width":0.23194444,"height":0.0011111111},"on_screen":true,"role_description":"text"},{"role":"AXLink","text":"https://github.com/jiminny/app/pull/12115","depth":25,"bounds":{"left":0.7465278,"top":0.16111112,"width":0.19791667,"height":0.018888889},"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"https://github.com/jiminny/app/pull/12115","depth":26,"bounds":{"left":0.7465278,"top":0.16111112,"width":0.19791667,"height":0.018888889},"on_screen":true,"role_description":"text"},{"role":"AXLink","text":"Yesterday at 5:32:57 PM","depth":25,"bounds":{"left":0.72430557,"top":0.19666667,"width":0.016666668,"height":0.016666668},"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"5:32","depth":26,"bounds":{"left":0.72430557,"top":0.19666667,"width":0.016666668,"height":0.016666668},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"за github action-a","depth":25,"bounds":{"left":0.7465278,"top":0.19333333,"width":0.08194444,"height":0.02},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.7465278,"top":0.19333333,"width":0.0048611113,"height":0.02}},{"char_start":1,"char_count":17,"bounds":{"left":0.7513889,"top":0.19333333,"width":0.077083334,"height":0.02}}],"role_description":"text"},{"role":"AXCheckBox","text":"React with white_check_mark","depth":26,"bounds":{"left":0.8048611,"top":0.16111112,"width":0.022222223,"height":0.033333335},"on_screen":true,"role_description":"toggle button","subrole":"AXToggleButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"React with eyes","depth":26,"bounds":{"left":0.82708335,"top":0.16111112,"width":0.022222223,"height":0.033333335},"on_screen":true,"role_description":"toggle button","subrole":"AXToggleButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"React with raised_hands","depth":26,"bounds":{"left":0.84930557,"top":0.16111112,"width":0.022222223,"height":0.033333335},"on_screen":true,"role_description":"toggle button","subrole":"AXToggleButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Add reaction…","depth":26,"bounds":{"left":0.8715278,"top":0.16111112,"width":0.022222223,"height":0.033333335},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Reply in thread","depth":26,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Forward message…","depth":26,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Save for later","depth":26,"on_screen":false,"role_description":"toggle button","subrole":"AXToggleButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXPopUpButton","text":"More actions","depth":26,"on_screen":false,"role_description":"pop-up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXPopUpButton","text":"Jump to date","depth":23,"bounds":{"left":0.8229167,"top":0.23555556,"width":0.05277778,"height":0.031111112},"on_screen":true,"role_description":"pop-up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"New","depth":23,"bounds":{"left":0.96458334,"top":0.24111111,"width":0.01875,"height":0.018888889},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Galya Dimitrova","depth":24,"bounds":{"left":0.7465278,"top":0.27888888,"width":0.07638889,"height":0.024444444},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":24,"bounds":{"left":0.8361111,"top":0.28111112,"width":0.0055555557,"height":0.02},"on_screen":true,"role_description":"text"},{"role":"AXLink","text":"Today at 1:24:02 PM","depth":24,"bounds":{"left":0.84166664,"top":0.28444445,"width":0.031944446,"height":0.016666668},"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"1:24 PM","depth":25,"bounds":{"left":0.84166664,"top":0.28444445,"width":0.031944446,"height":0.016666668},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Сторита за рефайнмънт за вторник:","depth":25,"bounds":{"left":0.7465278,"top":0.30555555,"width":0.17291667,"height":0.02},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":27,"bounds":{"left":0.74930555,"top":0.33222222,"width":0.010416667,"height":0.017777778},"on_screen":true,"role_description":"text"},{"role":"AXLink","text":"https://jiminny.atlassian.net/browse/JY-20834","depth":26,"bounds":{"left":0.7659722,"top":0.33,"width":0.21111111,"height":0.02},"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"https://jiminny.atlassian.net/browse/JY-20834","depth":27,"bounds":{"left":0.7659722,"top":0.33,"width":0.21111111,"height":0.02},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"- това го гледахме и преди но вкарах дизайн и по подробно описание и е добре пак да го минем","depth":26,"bounds":{"left":0.7659722,"top":0.35444444,"width":0.21527778,"height":0.06888889},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.7659722,"top":0.35444444,"width":0.004166667,"height":0.02}},{"char_start":1,"char_count":91,"bounds":{"left":0.7659722,"top":0.35444444,"width":0.21527778,"height":0.06888889}}],"role_description":"text"},{"role":"AXStaticText","text":"","depth":27,"bounds":{"left":0.74930555,"top":0.43,"width":0.010416667,"height":0.017777778},"on_screen":true,"role_description":"text"},{"role":"AXLink","text":"https://jiminny.atlassian.net/browse/JY-20836","depth":26,"bounds":{"left":0.7659722,"top":0.42777777,"width":0.21111111,"height":0.02},"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"https://jiminny.atlassian.net/browse/JY-20836","depth":27,"bounds":{"left":0.7659722,"top":0.42777777,"width":0.21111111,"height":0.02},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":27,"bounds":{"left":0.74930555,"top":0.45444444,"width":0.010416667,"height":0.017777778},"on_screen":true,"role_description":"text"},{"role":"AXLink","text":"https://jiminny.atlassian.net/browse/JY-20813","depth":26,"bounds":{"left":0.7659722,"top":0.45222223,"width":0.21111111,"height":0.02},"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"https://jiminny.atlassian.net/browse/JY-20813","depth":27,"bounds":{"left":0.7659722,"top":0.45222223,"width":0.21111111,"height":0.02},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":27,"bounds":{"left":0.74930555,"top":0.4788889,"width":0.010416667,"height":0.017777778},"on_screen":true,"role_description":"text"},{"role":"AXLink","text":"https://jiminny.atlassian.net/browse/JY-20814","depth":26,"bounds":{"left":0.7659722,"top":0.47666666,"width":0.21111111,"height":0.02},"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"https://jiminny.atlassian.net/browse/JY-20814","depth":27,"bounds":{"left":0.7659722,"top":0.47666666,"width":0.21111111,"height":0.02},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"4 attachments","depth":24,"bounds":{"left":0.7465278,"top":0.5088889,"width":0.056944445,"height":0.017777778},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"4 attachments","depth":24,"bounds":{"left":0.8055556,"top":0.5088889,"width":0.011805556,"height":0.017777778},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":true},{"role":"AXButton","text":"Jira Cloud Story JY-20834 MCP > Authenticate each user for easy access Story JY-20834 in Jira Cloud Preview in Slack","depth":26,"bounds":{"left":0.7465278,"top":0.5322222,"width":0.2361111,"height":0.07333333},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"MCP > Authenticate each user for easy access","depth":27,"bounds":{"left":0.7888889,"top":0.5511111,"width":0.18263888,"height":0.04},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.7888889,"top":0.5511111,"width":0.009722223,"height":0.02}},{"char_start":1,"char_count":43,"bounds":{"left":0.7888889,"top":0.5511111,"width":0.18194444,"height":0.02}}],"role_description":"text"},{"role":"AXStaticText","text":"Story JY-20834 in Jira Cloud","depth":28,"bounds":{"left":0.7888889,"top":0.5733333,"width":0.11319444,"height":0.017777778},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.7888889,"top":0.5733333,"width":0.0048611113,"height":0.017777778}},{"char_start":1,"char_count":27,"bounds":{"left":0.79375,"top":0.5733333,"width":0.108333334,"height":0.017777778}}],"role_description":"text"},{"role":"AXStaticText","text":"Preview in Slack","depth":28,"bounds":{"left":0.7888889,"top":0.5733333,"width":0.06527778,"height":0.017777778},"on_screen":true,"role_description":"text"},{"role":"AXLink","text":"Open in browser","depth":28,"bounds":{"left":0.8819444,"top":0.5511111,"width":0.022222223,"height":0.035555556},"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Share Story JY-20834","depth":27,"bounds":{"left":0.90416664,"top":0.5511111,"width":0.022222223,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"View conversations","depth":27,"bounds":{"left":0.92638886,"top":0.5511111,"width":0.022222223,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More actions","depth":27,"bounds":{"left":0.94861114,"top":0.5511111,"width":0.022222223,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Jira Cloud Story JY-20836 MCP > Audit log Story JY-20836 in Jira Cloud Preview in Slack","depth":26,"bounds":{"left":0.7465278,"top":0.61444443,"width":0.2361111,"height":0.07333333},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"MCP > Audit log","depth":27,"bounds":{"left":0.7888889,"top":0.6333333,"width":0.07569444,"height":0.02},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Story JY-20836 in Jira Cloud","depth":28,"bounds":{"left":0.7888889,"top":0.65555555,"width":0.11319444,"height":0.017777778},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.7888889,"top":0.65555555,"width":0.0048611113,"height":0.017777778}},{"char_start":1,"char_count":27,"bounds":{"left":0.79375,"top":0.65555555,"width":0.108333334,"height":0.017777778}}],"role_description":"text"},{"role":"AXStaticText","text":"Preview in Slack","depth":28,"bounds":{"left":0.7888889,"top":0.65555555,"width":0.06527778,"height":0.017777778},"on_screen":true,"role_description":"text"},{"role":"AXLink","text":"Open in browser","depth":28,"bounds":{"left":0.8819444,"top":0.6333333,"width":0.022222223,"height":0.035555556},"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Share Story JY-20836","depth":27,"bounds":{"left":0.90416664,"top":0.6333333,"width":0.022222223,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"View conversations","depth":27,"bounds":{"left":0.92638886,"top":0.6333333,"width":0.022222223,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More actions","depth":27,"bounds":{"left":0.94861114,"top":0.6333333,"width":0.022222223,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Jira Cloud Bug JY-20813 Twilio number is not released when Voice role is removed from user Bug JY-20813 in Jira Cloud Preview in Slack","depth":26,"bounds":{"left":0.7465278,"top":0.69666666,"width":0.2361111,"height":0.07333333},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Twilio number is not released when Voice role is removed from user","depth":28,"bounds":{"left":0.7888889,"top":0.71555555,"width":0.17986111,"height":0.02},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.7888889,"top":0.71555555,"width":0.0055555557,"height":0.02}},{"char_start":1,"char_count":65,"bounds":{"left":0.7888889,"top":0.71555555,"width":0.18055555,"height":0.02}}],"role_description":"text"},{"role":"AXStaticText","text":"Bug JY-20813 in Jira Cloud","depth":28,"bounds":{"left":0.7888889,"top":0.73777777,"width":0.108333334,"height":0.017777778},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Preview in Slack","depth":28,"bounds":{"left":0.7888889,"top":0.73777777,"width":0.06527778,"height":0.017777778},"on_screen":true,"role_description":"text"},{"role":"AXLink","text":"Open in browser","depth":28,"bounds":{"left":0.8819444,"top":0.71555555,"width":0.022222223,"height":0.035555556},"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Share Bug JY-20813","depth":27,"bounds":{"left":0.90416664,"top":0.71555555,"width":0.022222223,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"View conversations","depth":27,"bounds":{"left":0.92638886,"top":0.71555555,"width":0.022222223,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More actions","depth":27,"bounds":{"left":0.94861114,"top":0.71555555,"width":0.022222223,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Jira Cloud Story JY-20814 Release unused Twilio numbers on Production Story JY-20814 in Jira Cloud Preview in Slack","depth":26,"bounds":{"left":0.7465278,"top":0.7788889,"width":0.2361111,"height":0.07333333},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Release unused Twilio numbers on Production","depth":27,"bounds":{"left":0.7888889,"top":0.7977778,"width":0.17986111,"height":0.04},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.7888889,"top":0.7977778,"width":0.00625,"height":0.02}},{"char_start":1,"char_count":42,"bounds":{"left":0.7888889,"top":0.7977778,"width":0.18333334,"height":0.02}}],"role_description":"text"},{"role":"AXStaticText","text":"Story JY-20814 in Jira Cloud","depth":28,"bounds":{"left":0.7888889,"top":0.82,"width":0.11319444,"height":0.017777778},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.7888889,"top":0.82,"width":0.0048611113,"height":0.017777778}},{"char_start":1,"char_count":27,"bounds":{"left":0.79375,"top":0.82,"width":0.108333334,"height":0.017777778}}],"role_description":"text"},{"role":"AXStaticText","text":"Preview in Slack","depth":28,"bounds":{"left":0.7888889,"top":0.82,"width":0.06527778,"height":0.017777778},"on_screen":true,"role_description":"text"},{"role":"AXLink","text":"Open in browser","depth":28,"bounds":{"left":0.8819444,"top":0.7977778,"width":0.022222223,"height":0.035555556},"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Share Story JY-20814","depth":27,"bounds":{"left":0.90416664,"top":0.7977778,"width":0.022222223,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"View conversations","depth":27,"bounds":{"left":0.92638886,"top":0.7977778,"width":0.022222223,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More actions","depth":27,"bounds":{"left":0.94861114,"top":0.7977778,"width":0.022222223,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"React with white_check_mark","depth":26,"bounds":{"left":0.8041667,"top":0.2611111,"width":0.022222223,"height":0.035555556},"on_screen":true,"role_description":"toggle button","subrole":"AXToggleButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"React with eyes","depth":26,"bounds":{"left":0.8263889,"top":0.2611111,"width":0.022222223,"height":0.035555556},"on_screen":true,"role_description":"toggle button","subrole":"AXToggleButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"React with raised_hands","depth":26,"bounds":{"left":0.8486111,"top":0.2611111,"width":0.022222223,"height":0.035555556},"on_screen":true,"role_description":"toggle button","subrole":"AXToggleButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Add reaction…","depth":26,"bounds":{"left":0.87083334,"top":0.2611111,"width":0.022222223,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Reply in thread","depth":26,"bounds":{"left":0.89305556,"top":0.2611111,"width":0.022222223,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Forward message…","depth":26,"bounds":{"left":0.9152778,"top":0.2611111,"width":0.022222223,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Save for later","depth":26,"bounds":{"left":0.9375,"top":0.2611111,"width":0.022222223,"height":0.035555556},"on_screen":true,"role_description":"toggle button","subrole":"AXToggleButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXPopUpButton","text":"More actions","depth":26,"bounds":{"left":0.9597222,"top":0.2611111,"width":0.022222223,"height":0.035555556},"on_screen":true,"role_description":"pop-up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"","depth":24,"bounds":{"left":0.71666664,"top":0.88,"width":0.26527777,"height":0.04222222},"on_screen":true,"value":"","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Processing uploaded file… complete! Message ready to be sent.","depth":11,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Channel platform-inner-team","depth":11,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Twilio number is not released when Voice role is removed from user","depth":13,"bounds":{"left":0.8229167,"top":0.6388889,"width":0.11736111,"height":0.057777777},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.8229167,"top":0.6388889,"width":0.0048611113,"height":0.017777778}},{"char_start":1,"char_count":65,"bounds":{"left":0.8263889,"top":0.6388889,"width":0.11388889,"height":0.057777777}}],"role_description":"text"}]...
|
-3657156747631512173
|
-4506693003704892940
|
click
|
hybrid
|
NULL
|
Switch workspaces… (Jiminny Inc) Has new messages
Switch workspaces… (Jiminny Inc) Has new messages
Home
Home
DMs
DMs
Activity
Activity
Files
Files
Later
Later
More…
More
Unreads
Threads
Huddles
Drafts & sent
1
Directories
jiminny-x-integration-app
platform-inner-team
ai-chapter
alerts
backend
bugs
confusion-clinic
curiosity_lab
engineering
general
happy_birthday
jiminny-bg
platform-tickets
product_launches
random
releases
sofia-office
support
thank-yous
the_people_of_jiminny
Galya Dimitrova
Nikolay Yankov
Aneliya Angelova
Stoyan Tanev
James Graham
Stefka Stoyanova
Vasil Vasilev
Stoyan Tomov
Todor Stamatov
Lukas Kovalik
you
Jira Cloud
Toast
Messages
Messages
Channel Overview
Channel Overview
More
Add and Edit Channel Tabs
Canvas
List
Folder
Jump to date
Lukas Kovalik
Yesterday at 9:44:16 AM
9:44 AM
добро утро, тази страница не трябва ли да я показваме само ако няма reports
CleanShot 2026-05-21 at [EMAIL]
Toggle file
CleanShot 2026-05-21 at [EMAIL]
Download CleanShot 2026-05-21 at [EMAIL]
Share file: CleanShot 2026-05-21 at [EMAIL]
View canvas details
More actions
4 replies
Last reply 1 day ago
View thread
Steliyan Georgiev
Yesterday at 3:05:08 PM
3:05 PM
Може ли лайк?
https://github.com/jiminny/prophet/pull/519
https://github.com/jiminny/prophet/pull/519
"This is ready to merge from a code quality standpoint."
Nikolay Yankov
Yesterday at 5:32:49 PM
5:32 PM
може ли 1 approve, че не мога да го тествам без да merge в мастер
https://github.com/jiminny/app/pull/12115
https://github.com/jiminny/app/pull/12115
Yesterday at 5:32:57 PM
5:32
за github action-a
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Jump to date
New
Galya Dimitrova
Today at 1:24:02 PM
1:24 PM
Сторита за рефайнмънт за вторник:
https://jiminny.atlassian.net/browse/JY-20834
https://jiminny.atlassian.net/browse/JY-20834
- това го гледахме и преди но вкарах дизайн и по подробно описание и е добре пак да го минем
https://jiminny.atlassian.net/browse/JY-20836
https://jiminny.atlassian.net/browse/JY-20836
https://jiminny.atlassian.net/browse/JY-20813
https://jiminny.atlassian.net/browse/JY-20813
https://jiminny.atlassian.net/browse/JY-20814
https://jiminny.atlassian.net/browse/JY-20814
4 attachments
4 attachments
Jira Cloud Story JY-20834 MCP > Authenticate each user for easy access Story JY-20834 in Jira Cloud Preview in Slack
MCP > Authenticate each user for easy access
Story JY-20834 in Jira Cloud
Preview in Slack
Open in browser
Share Story JY-20834
View conversations
More actions
Jira Cloud Story JY-20836 MCP > Audit log Story JY-20836 in Jira Cloud Preview in Slack
MCP > Audit log
Story JY-20836 in Jira Cloud
Preview in Slack
Open in browser
Share Story JY-20836
View conversations
More actions
Jira Cloud Bug JY-20813 Twilio number is not released when Voice role is removed from user Bug JY-20813 in Jira Cloud Preview in Slack
Twilio number is not released when Voice role is removed from user
Bug JY-20813 in Jira Cloud
Preview in Slack
Open in browser
Share Bug JY-20813
View conversations
More actions
Jira Cloud Story JY-20814 Release unused Twilio numbers on Production Story JY-20814 in Jira Cloud Preview in Slack
Release unused Twilio numbers on Production
Story JY-20814 in Jira Cloud
Preview in Slack
Open in browser
Share Story JY-20814
View conversations
More actions
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Processing uploaded file… complete! Message ready to be sent.
Channel platform-inner-team
Twilio number is not released when Voice role is removed from user
SlackFileEditViewGoHistoryWindowHelpDOCKERDEV (-zsh)O $82APP (-zsh)tests/Unit/Services/Mail/0ffice/EmailApiClientTest.phptests/Unit/Services/Mail/TextRelayServiceTest.phptests/Unit/Services/RecallAI/RecallAIServiceTest.php79 files changed, 4254 insertions(+), 2020 deletions(-)create mode 100644•github/claude-reviewer/no-ticket-warning.txtcreate mode100644.github/claude-reviewer/scripts/fetch-jira-context.mjsdelete mode100644 app/Component/ES/Processor/BatchStatusInterface.phpcreate mode100644 app/Component/ES/Processor/RedisEntityExtractor.phpdelete mode100644 app/Component/ES/Processor/Traits/WorkerIdTrait.phpcreate mode100644 app/Component/ES/QueuePriorityEnum.phpcreate mode100644 front-end/src/components/DealInsights/Modals/__tests__/AskAnythingModacreate mode 100644 front-end/src/components/shared/AskAnything/__tests__/__snapshots__/Asdelete mode100644 front-end/src/components/shared/AskAnything/__tests__/_snapshots_./Ascreate mode 100644 tests/Unit/Component/ES/Processor/RedisEntityExtractorTest.phpdelete mode 100644 tests/Unit/Component/ES/Processor/Traits/WorkerIdTraitTest.phpcreate mode100644 tests/Unit/Component/ES/QueuePriorityEnumTest.phpcreate mode 100644 tests/Unit/Notifications/Activities/SmsReceivedTest.phpcreate mode100644 tests/Unit/Services/Mail/TextRelayServiceTest.phplukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ co -b JY-20963-fix-imporSwitched to a new branch 'JY-20963-fix-import-on-deleted-entity'lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/Jiminny/app (JY-20963-fix-import-on-deleted-ent|docker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix--config=.php-cs-fixer.dist.phWhat's next:Try Docker Debug forseamless, persistent debugging tools in any container or image →Learn moreat [URL_WITH_CREDENTIALS] ~/jiminny/app (JY-20963-fix-import-on-deleted-ent|docker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.phPHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminskiandcontributors.PHP runtime: 8.3.30Running analysis on 7 cores with 10 files per process.Parallel runner is an experimental feature and may be unstable, use it at your own risk.Loaded config default from".php-cs-fixer.dist.php".5691/5691 [8100%Fixed 0 of 5691 files in 96.077 seconds, 67.00 MB memory usedWhat's next:Try Docker Debug for seamless, persistent debugging tools in anycontainer orimage →Learn moreat https://docs.docker.com/go/debug-cli/lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20963-fix-import-on-deleted-eAPP (...ED→HomeDMsActivityFilesLater...More+Jiminny ...w Starrea& jiminny-x-integrati...8platform-inner-teamChannels# ai-chapter# alerts# backend# bugs# confusion-clinic# curiosity_lab# engineering# general# happy_birthday# jiminny-bg# platform-tickets# product_launches# random# releases# sofia-office# support# thank-yous# the_people_of_jimi...• Direct messages. Galya Dimitrova "o Nikolay YankovR. Aneliya Angelova. Stoyan TanevIomac Grshamlahl100% <478•Fri 22 May 13:27:24Describe what you are looking for* & platform-inner-team& 106 0• MessagesP Channel OverviewMore ~+psa/ ginav.comzummnvrapp/panz 12119Yesterday~за github action .Today~NewGalya Dimit.Сторита за рефайнмьнт за вторник:• https://jiminny.atlassian.net/browse/JY-20834- това го гледахме и преди но вкарах дизайни по подробно описание и е добре пак да гоминем• https://jiminny.atlassian.net/browse/JY-20836http://iminyatassan.net/browse/Y/208134 attachments •MCP > Authenticate each user for eas...Story JY-20834 in Jira CloudMCPS StoryTwilio number is not releasedwhen Voice role is removedfrom userTwilio number is noPreview in SlackRelease unused Twilio numbers on Pr...# Story JY-20814 in Jira CloudMessage & platform-inner-team+...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
70654
|
2536
|
20
|
2026-05-22T10:27:10.360002+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779445630360_m1.jpg...
|
Slack
|
platform-inner-team (Channel) - Jiminny Inc - 2 ne platform-inner-team (Channel) - Jiminny Inc - 2 new items - Slack...
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Switch workspaces… (Jiminny Inc) Has new messages
Switch workspaces… (Jiminny Inc) Has new messages
Home
Home
DMs
DMs
Activity
Activity
Files
Files
Later
Later
More…
More
Unreads
Threads
Huddles
Drafts & sent
1
Directories
jiminny-x-integration-app
platform-inner-team
ai-chapter
alerts
backend
bugs
confusion-clinic
curiosity_lab
engineering
general
happy_birthday
jiminny-bg
platform-tickets
product_launches
random
releases
sofia-office
support
thank-yous
the_people_of_jiminny
Galya Dimitrova
Nikolay Yankov
Aneliya Angelova
Stoyan Tanev
James Graham
Stefka Stoyanova
Vasil Vasilev
Stoyan Tomov
Todor Stamatov
Lukas Kovalik
you
Jira Cloud
Toast
Messages
Messages
Channel Overview
Channel Overview
More
Add and Edit Channel Tabs
Canvas
List
Folder
Jump to date
Lukas Kovalik
Yesterday at 9:44:16 AM
9:44 AM
добро утро, тази страница не трябва ли да я показваме само ако няма reports
CleanShot 2026-05-21 at [EMAIL]
Toggle file
CleanShot 2026-05-21 at [EMAIL]
Download CleanShot 2026-05-21 at [EMAIL]
Share file: CleanShot 2026-05-21 at [EMAIL]
View canvas details
More actions
4 replies
Last reply 1 day ago
View thread
Steliyan Georgiev
Yesterday at 3:05:08 PM
3:05 PM
Може ли лайк?
https://github.com/jiminny/prophet/pull/519
https://github.com/jiminny/prophet/pull/519
"This is ready to merge from a code quality standpoint."
Nikolay Yankov
Yesterday at 5:32:49 PM
5:32 PM
може ли 1 approve, че не мога да го тествам без да merge в мастер
https://github.com/jiminny/app/pull/12115
https://github.com/jiminny/app/pull/12115
Yesterday at 5:32:57 PM
5:32
за github action-a
React with white_check_mark...
|
[{"role":"AXPopUpButton","text [{"role":"AXPopUpButton","text":"Switch workspaces… (Jiminny Inc) Has new messages","depth":14,"bounds":{"left":0.51180553,"top":0.08111111,"width":0.025,"height":0.04},"on_screen":true,"role_description":"pop-up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXRadioButton","text":"Home","depth":14,"bounds":{"left":0.50625,"top":0.14,"width":0.036111113,"height":0.075555556},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":true,"is_expanded":false},{"role":"AXStaticText","text":"Home","depth":16,"bounds":{"left":0.5138889,"top":0.19222222,"width":0.020833334,"height":0.015555556},"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"DMs","depth":14,"bounds":{"left":0.50625,"top":0.21555555,"width":0.036111113,"height":0.075555556},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"DMs","depth":16,"bounds":{"left":0.5159722,"top":0.26777777,"width":0.016666668,"height":0.015555556},"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"Activity","depth":14,"bounds":{"left":0.50625,"top":0.2911111,"width":0.036111113,"height":0.075555556},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Activity","depth":16,"bounds":{"left":0.51111114,"top":0.34333333,"width":0.027083334,"height":0.015555556},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.51111114,"top":0.34333333,"width":0.0055555557,"height":0.015555556}},{"char_start":1,"char_count":7,"bounds":{"left":0.5159722,"top":0.34333333,"width":0.022222223,"height":0.015555556}}],"role_description":"text"},{"role":"AXRadioButton","text":"Files","depth":14,"bounds":{"left":0.50625,"top":0.36666667,"width":0.036111113,"height":0.075555556},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Files","depth":16,"bounds":{"left":0.51666665,"top":0.4188889,"width":0.015972223,"height":0.015555556},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.51666665,"top":0.4188889,"width":0.004166667,"height":0.015555556}},{"char_start":1,"char_count":4,"bounds":{"left":0.5208333,"top":0.4188889,"width":0.011805556,"height":0.015555556}}],"role_description":"text"},{"role":"AXRadioButton","text":"Later","depth":14,"bounds":{"left":0.50625,"top":0.4422222,"width":0.036111113,"height":0.075555556},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Later","depth":16,"bounds":{"left":0.5152778,"top":0.49444443,"width":0.018055556,"height":0.015555556},"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"More…","depth":14,"bounds":{"left":0.50625,"top":0.5177778,"width":0.036111113,"height":0.075555556},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"More","depth":16,"bounds":{"left":0.5152778,"top":0.57,"width":0.01875,"height":0.015555556},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Unreads","depth":21,"bounds":{"left":0.57708335,"top":0.12777779,"width":0.039583333,"height":0.0011111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Threads","depth":21,"bounds":{"left":0.57708335,"top":0.12777779,"width":0.036805555,"height":0.0011111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Huddles","depth":21,"bounds":{"left":0.57708335,"top":0.12777779,"width":0.038194444,"height":0.0011111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Drafts & sent","depth":21,"bounds":{"left":0.57708335,"top":0.12777779,"width":0.06111111,"height":0.0011111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":21,"bounds":{"left":0.68472224,"top":0.12777779,"width":0.0055555557,"height":0.0011111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Directories","depth":21,"bounds":{"left":0.57708335,"top":0.12777779,"width":0.050694443,"height":0.0011111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"jiminny-x-integration-app","depth":23,"bounds":{"left":0.58819443,"top":0.15111111,"width":0.09166667,"height":0.02},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.58819443,"top":0.15111111,"width":0.0027777778,"height":0.02}},{"char_start":1,"char_count":24,"bounds":{"left":0.59097224,"top":0.15111111,"width":0.11319444,"height":0.02}}],"role_description":"text"},{"role":"AXStaticText","text":"platform-inner-team","depth":23,"bounds":{"left":0.58819443,"top":0.18222222,"width":0.093055554,"height":0.02},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ai-chapter","depth":23,"bounds":{"left":0.58819443,"top":0.25555557,"width":0.046527777,"height":0.02},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"alerts","depth":23,"bounds":{"left":0.58819443,"top":0.28666666,"width":0.025694445,"height":0.02},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.58819443,"top":0.28666666,"width":0.0055555557,"height":0.02}},{"char_start":1,"char_count":5,"bounds":{"left":0.59375,"top":0.28666666,"width":0.019444445,"height":0.02}}],"role_description":"text"},{"role":"AXStaticText","text":"backend","depth":23,"bounds":{"left":0.58819443,"top":0.31777778,"width":0.038194444,"height":0.02},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"bugs","depth":23,"bounds":{"left":0.58819443,"top":0.34888887,"width":0.022222223,"height":0.02},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"confusion-clinic","depth":23,"bounds":{"left":0.58819443,"top":0.38,"width":0.072222225,"height":0.02},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.58819443,"top":0.38,"width":0.0048611113,"height":0.02}},{"char_start":1,"char_count":15,"bounds":{"left":0.59305555,"top":0.38,"width":0.06736111,"height":0.02}}],"role_description":"text"},{"role":"AXStaticText","text":"curiosity_lab","depth":23,"bounds":{"left":0.58819443,"top":0.41111112,"width":0.057638887,"height":0.02},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.58819443,"top":0.41111112,"width":0.0048611113,"height":0.02}},{"char_start":1,"char_count":12,"bounds":{"left":0.59305555,"top":0.41111112,"width":0.05277778,"height":0.02}}],"role_description":"text"},{"role":"AXStaticText","text":"engineering","depth":23,"bounds":{"left":0.58819443,"top":0.4422222,"width":0.054166667,"height":0.02},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"general","depth":23,"bounds":{"left":0.58819443,"top":0.47333333,"width":0.034027778,"height":0.02},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"happy_birthday","depth":23,"bounds":{"left":0.58819443,"top":0.5044444,"width":0.07083333,"height":0.02},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"jiminny-bg","depth":23,"bounds":{"left":0.58819443,"top":0.53555554,"width":0.048611112,"height":0.02},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"platform-tickets","depth":23,"bounds":{"left":0.58819443,"top":0.56666666,"width":0.072916664,"height":0.02},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.58819443,"top":0.56666666,"width":0.00625,"height":0.02}},{"char_start":1,"char_count":15,"bounds":{"left":0.59444445,"top":0.56666666,"width":0.06666667,"height":0.02}}],"role_description":"text"},{"role":"AXStaticText","text":"product_launches","depth":23,"bounds":{"left":0.58819443,"top":0.5977778,"width":0.08055556,"height":0.02},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"random","depth":23,"bounds":{"left":0.58819443,"top":0.6288889,"width":0.035416666,"height":0.02},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"releases","depth":23,"bounds":{"left":0.58819443,"top":0.66,"width":0.038194444,"height":0.02},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"sofia-office","depth":23,"bounds":{"left":0.58819443,"top":0.6911111,"width":0.05138889,"height":0.02},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.58819443,"top":0.6911111,"width":0.0048611113,"height":0.02}},{"char_start":1,"char_count":11,"bounds":{"left":0.59305555,"top":0.6911111,"width":0.045833334,"height":0.02}}],"role_description":"text"},{"role":"AXStaticText","text":"support","depth":23,"bounds":{"left":0.58819443,"top":0.7222222,"width":0.036111113,"height":0.02},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"thank-yous","depth":23,"bounds":{"left":0.58819443,"top":0.75333333,"width":0.05138889,"height":0.02},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"the_people_of_jiminny","depth":23,"bounds":{"left":0.58819443,"top":0.78444445,"width":0.094444446,"height":0.02},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.58819443,"top":0.78444445,"width":0.004166667,"height":0.02}},{"char_start":1,"char_count":20,"bounds":{"left":0.5923611,"top":0.78444445,"width":0.09861111,"height":0.02}}],"role_description":"text"},{"role":"AXStaticText","text":"Galya Dimitrova","depth":23,"bounds":{"left":0.58819443,"top":0.8577778,"width":0.07361111,"height":0.02},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Nikolay Yankov","depth":23,"bounds":{"left":0.58819443,"top":0.8888889,"width":0.06875,"height":0.02},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Aneliya Angelova","depth":23,"bounds":{"left":0.58819443,"top":0.92,"width":0.07847222,"height":0.02},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Stoyan Tanev","depth":23,"bounds":{"left":0.58819443,"top":0.95111114,"width":0.060416665,"height":0.02},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"James Graham","depth":23,"bounds":{"left":0.58819443,"top":0.9822222,"width":0.06666667,"height":0.012222222},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Stefka Stoyanova","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Vasil Vasilev","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Stoyan Tomov","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Todor Stamatov","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Lukas Kovalik","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"you","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Jira Cloud","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Toast","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXRadioButton","text":"Messages","depth":18,"bounds":{"left":0.71319443,"top":0.12777779,"width":0.06458333,"height":0.04222222},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":true,"is_expanded":false},{"role":"AXStaticText","text":"Messages","depth":20,"bounds":{"left":0.7326389,"top":0.14,"width":0.039583333,"height":0.017777778},"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"Channel Overview","depth":18,"bounds":{"left":0.7798611,"top":0.12777779,"width":0.1,"height":0.04222222},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Channel Overview","depth":20,"bounds":{"left":0.79930556,"top":0.14,"width":0.075,"height":0.017777778},"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"More","depth":19,"bounds":{"left":0.8819444,"top":0.12777779,"width":0.04236111,"height":0.04222222},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXPopUpButton","text":"Add and Edit Channel Tabs","depth":18,"bounds":{"left":0.92430556,"top":0.12777779,"width":0.022222223,"height":0.04222222},"on_screen":true,"role_description":"pop-up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Canvas","depth":18,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"List","depth":18,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Folder","depth":18,"on_screen":false,"role_description":"text"},{"role":"AXPopUpButton","text":"Jump to date","depth":23,"bounds":{"left":0.81527776,"top":0.17666666,"width":0.068055555,"height":0.031111112},"on_screen":true,"role_description":"pop-up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Lukas Kovalik","depth":24,"bounds":{"left":0.7465278,"top":0.16111112,"width":0.06458333,"height":0.0011111111},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":24,"bounds":{"left":0.8111111,"top":0.16111112,"width":0.0055555557,"height":0.0011111111},"on_screen":true,"role_description":"text"},{"role":"AXLink","text":"Yesterday at 9:44:16 AM","depth":24,"bounds":{"left":0.81666666,"top":0.16111112,"width":0.031944446,"height":0.0011111111},"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"9:44 AM","depth":25,"bounds":{"left":0.81666666,"top":0.16111112,"width":0.031944446,"height":0.0011111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"добро утро, тази страница не трябва ли да я показваме само ако няма reports","depth":25,"bounds":{"left":0.7465278,"top":0.16111112,"width":0.21527778,"height":0.0011111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"CleanShot 2026-05-21 at 09.43.20@2x.png","depth":25,"bounds":{"left":0.7465278,"top":0.16111112,"width":0.1736111,"height":0.0011111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":25,"bounds":{"left":0.9201389,"top":0.16111112,"width":0.0027777778,"height":0.0011111111},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Toggle file","depth":25,"bounds":{"left":0.92291665,"top":0.16111112,"width":0.013888889,"height":0.0011111111},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":true},{"role":"AXLink","text":"CleanShot 2026-05-21 at 09.43.20@2x.png","depth":27,"bounds":{"left":0.7465278,"top":0.16111112,"width":0.2361111,"height":0.0011111111},"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"Download CleanShot 2026-05-21 at 09.43.20@2x.png","depth":28,"bounds":{"left":0.8840278,"top":0.16111112,"width":0.022222223,"height":0.0011111111},"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Share file: CleanShot 2026-05-21 at 09.43.20@2x.png","depth":28,"bounds":{"left":0.90625,"top":0.16111112,"width":0.022222223,"height":0.0011111111},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"View canvas details","depth":28,"bounds":{"left":0.9284722,"top":0.16111112,"width":0.022222223,"height":0.0011111111},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXPopUpButton","text":"More actions","depth":28,"bounds":{"left":0.95069444,"top":0.16111112,"width":0.022222223,"height":0.0011111111},"on_screen":true,"role_description":"pop-up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"4 replies","depth":24,"bounds":{"left":0.80694443,"top":0.16111112,"width":0.034027778,"height":0.0011111111},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Last reply 1 day ago","depth":25,"bounds":{"left":0.84652776,"top":0.16111112,"width":0.07847222,"height":0.0011111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"View thread","depth":25,"bounds":{"left":0.84652776,"top":0.16111112,"width":0.047916666,"height":0.0011111111},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Steliyan Georgiev","depth":24,"bounds":{"left":0.7465278,"top":0.16111112,"width":0.08263889,"height":0.0011111111},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":24,"bounds":{"left":0.82916665,"top":0.16111112,"width":0.0055555557,"height":0.0011111111},"on_screen":true,"role_description":"text"},{"role":"AXLink","text":"Yesterday at 3:05:08 PM","depth":24,"bounds":{"left":0.83402777,"top":0.16111112,"width":0.031944446,"height":0.0011111111},"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"3:05 PM","depth":25,"bounds":{"left":0.83402777,"top":0.16111112,"width":0.031944446,"height":0.0011111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Може ли лайк?","depth":25,"bounds":{"left":0.7465278,"top":0.16111112,"width":0.07361111,"height":0.0011111111},"on_screen":true,"role_description":"text"},{"role":"AXLink","text":"https://github.com/jiminny/prophet/pull/519","depth":25,"bounds":{"left":0.7465278,"top":0.16111112,"width":0.20555556,"height":0.0011111111},"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"https://github.com/jiminny/prophet/pull/519","depth":26,"bounds":{"left":0.7465278,"top":0.16111112,"width":0.20555556,"height":0.0011111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"\"This is ready to merge from a code quality standpoint.\"","depth":25,"bounds":{"left":0.7465278,"top":0.16111112,"width":0.23055555,"height":0.0011111111},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Nikolay Yankov","depth":24,"bounds":{"left":0.7465278,"top":0.16111112,"width":0.072222225,"height":0.0011111111},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":24,"bounds":{"left":0.81805557,"top":0.16111112,"width":0.00625,"height":0.0011111111},"on_screen":true,"role_description":"text"},{"role":"AXLink","text":"Yesterday at 5:32:49 PM","depth":24,"bounds":{"left":0.82361114,"top":0.16111112,"width":0.031944446,"height":0.0011111111},"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"5:32 PM","depth":25,"bounds":{"left":0.82361114,"top":0.16111112,"width":0.031944446,"height":0.0011111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"може ли 1 approve, че не мога да го тествам без да merge в мастер","depth":25,"bounds":{"left":0.7465278,"top":0.16111112,"width":0.23194444,"height":0.0011111111},"on_screen":true,"role_description":"text"},{"role":"AXLink","text":"https://github.com/jiminny/app/pull/12115","depth":25,"bounds":{"left":0.7465278,"top":0.16111112,"width":0.19791667,"height":0.018888889},"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"https://github.com/jiminny/app/pull/12115","depth":26,"bounds":{"left":0.7465278,"top":0.16111112,"width":0.19791667,"height":0.018888889},"on_screen":true,"role_description":"text"},{"role":"AXLink","text":"Yesterday at 5:32:57 PM","depth":25,"bounds":{"left":0.72430557,"top":0.19666667,"width":0.016666668,"height":0.016666668},"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"5:32","depth":26,"bounds":{"left":0.72430557,"top":0.19666667,"width":0.016666668,"height":0.016666668},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"за github action-a","depth":25,"bounds":{"left":0.7465278,"top":0.19333333,"width":0.08194444,"height":0.02},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.7465278,"top":0.19333333,"width":0.0048611113,"height":0.02}},{"char_start":1,"char_count":17,"bounds":{"left":0.7513889,"top":0.19333333,"width":0.077083334,"height":0.02}}],"role_description":"text"},{"role":"AXCheckBox","text":"React with white_check_mark","depth":26,"bounds":{"left":0.8048611,"top":0.16111112,"width":0.022222223,"height":0.033333335},"on_screen":true,"role_description":"toggle button","subrole":"AXToggleButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
7601845567611068918
|
-4467852159218350675
|
visual_change
|
hybrid
|
NULL
|
Switch workspaces… (Jiminny Inc) Has new messages
Switch workspaces… (Jiminny Inc) Has new messages
Home
Home
DMs
DMs
Activity
Activity
Files
Files
Later
Later
More…
More
Unreads
Threads
Huddles
Drafts & sent
1
Directories
jiminny-x-integration-app
platform-inner-team
ai-chapter
alerts
backend
bugs
confusion-clinic
curiosity_lab
engineering
general
happy_birthday
jiminny-bg
platform-tickets
product_launches
random
releases
sofia-office
support
thank-yous
the_people_of_jiminny
Galya Dimitrova
Nikolay Yankov
Aneliya Angelova
Stoyan Tanev
James Graham
Stefka Stoyanova
Vasil Vasilev
Stoyan Tomov
Todor Stamatov
Lukas Kovalik
you
Jira Cloud
Toast
Messages
Messages
Channel Overview
Channel Overview
More
Add and Edit Channel Tabs
Canvas
List
Folder
Jump to date
Lukas Kovalik
Yesterday at 9:44:16 AM
9:44 AM
добро утро, тази страница не трябва ли да я показваме само ако няма reports
CleanShot 2026-05-21 at [EMAIL]
Toggle file
CleanShot 2026-05-21 at [EMAIL]
Download CleanShot 2026-05-21 at [EMAIL]
Share file: CleanShot 2026-05-21 at [EMAIL]
View canvas details
More actions
4 replies
Last reply 1 day ago
View thread
Steliyan Georgiev
Yesterday at 3:05:08 PM
3:05 PM
Може ли лайк?
https://github.com/jiminny/prophet/pull/519
https://github.com/jiminny/prophet/pull/519
"This is ready to merge from a code quality standpoint."
Nikolay Yankov
Yesterday at 5:32:49 PM
5:32 PM
може ли 1 approve, че не мога да го тествам без да merge в мастер
https://github.com/jiminny/app/pull/12115
https://github.com/jiminny/app/pull/12115
Yesterday at 5:32:57 PM
5:32
за github action-a
React with white_check_mark
SlackFileEditViewGoHistoryWindowHelpDOCKERDEV (-zsh)O $82APP (-zsh)tests/Unit/Services/Mail/0ffice/EmailApiClientTest.phptests/Unit/Services/Mail/TextRelayServiceTest.phptests/Unit/Services/RecallAI/RecallAIServiceTest.php79 files changed, 4254 insertions(+), 2020 deletions(-)create mode 100644•github/claude-reviewer/no-ticket-warning.txtcreate mode100644.github/claude-reviewer/scripts/fetch-jira-context.mjsdelete mode100644 app/Component/ES/Processor/BatchStatusInterface.phpcreate mode100644 app/Component/ES/Processor/RedisEntityExtractor.phpdelete mode100644 app/Component/ES/Processor/Traits/WorkerIdTrait.phpcreate mode100644 app/Component/ES/QueuePriorityEnum.phpcreate mode100644 front-end/src/components/DealInsights/Modals/__tests__/AskAnythingModacreate mode 100644 front-end/src/components/shared/AskAnything/__tests__/__snapshots__/Asdelete mode100644 front-end/src/components/shared/AskAnything/__tests__/_create mode 100644 tests/Unit/Component/ES/Processor/RedisEntityExtractorTest.phpdelete mode 100644 tests/Unit/Component/ES/Processor/Traits/WorkerIdTraitTest.phpcreate mode100644 tests/Unit/Component/ES/QueuePriorityEnumTest.phpcreate mode 100644 tests/Unit/Notifications/Activities/SmsReceivedTest.phpcreate mode100644 tests/Unit/Services/Mail/TextRelayServiceTest.phplukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ co -b JY-20963-fix-imporSwitched to a new branch 'JY-20963-fix-import-on-deleted-entity'lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/Jiminny/app (JY-20963-fix-import-on-deleted-ent|docker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix--config=.php-cs-fixer.dist.phWhat's next:Try Docker Debug forseamless, persistent debugging tools in any container or image →Learn moreat [URL_WITH_CREDENTIALS] ~/jiminny/app (JY-20963-fix-import-on-deleted-ent|docker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.phPHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminskiandcontributors.PHP runtime: 8.3.30Running analysis on 7 cores with 10 files per process.Parallel runner is an experimental feature and may be unstable, use it at your own risk.Loaded config default from".php-cs-fixer.dist.php".5691/5691 [8100%Fixed 0 of 5691 files in 96.077 seconds, 67.00 MB memory usedWhat's next:Try Docker Debug for seamless, persistent debugging tools in anycontainer orimage →Learn moreat https://docs.docker.com/go/debug-cli/lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20963-fix-import-on-deleted-eAPP (...ED→HomeDMsActivityFilesLater...More+Jiminny ...w Starrea& jiminny-x-integrati...8platform-inner-teamChannels# ai-chapter# alerts# backend# bugs# confusion-clinic# curiosity_lab# engineering# general# happy_birthday# jiminny-bg# platform-tickets# product_launches# random# releases# sofia-office# support# thank-yous# the_people_of_jimi...• Direct messages%. Galya Dimitrova **o Nikolay YankovR. Aneliya Angelova. Stoyan TanevIomac Grshamlahl100% <478•Fri 22 May 13:27:10Describe what you are looking for* & platform-inner-team& 106 0• MessagesP Channel OverviewMore ~+p5//ginao.comzmmnnvrapp/pai 12119Yesterday~за github action .Today~NewGalya Dimit.Сторита за рефайнмьнт за вторник:• https://jiminny.atlassian.net/browse/JY-20834- това го гледахме и преди но вкарах дизайни по подробно описание и е добре пак да гоминем• https://jiminny.atlassian.net/browse/JY-20836http://iminyatassan.net/browse/Y/208134 attachments •MCP > AuthenticatPreview in SlackMCP > Audit logStory JY-20836 in Jira CloudTwilio number is not released when V...7 Bug JY-20813 in Jira CloudRelease unused Twilio numbers on Pr...# Story JY-20814 in Jira CloudMessage & platform-inner-team+...
|
70653
|
NULL
|
NULL
|
NULL
|
|
70653
|
2536
|
19
|
2026-05-22T10:27:07.258141+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779445627258_m1.jpg...
|
Slack
|
platform-inner-team (Channel) - Jiminny Inc - 2 ne platform-inner-team (Channel) - Jiminny Inc - 2 new items - Slack...
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Switch workspaces… (Jiminny Inc) Has new messages
Switch workspaces… (Jiminny Inc) Has new messages
Home
Home
DMs
DMs
Activity
Activity
Files
Files
Later
Later
More…
More
Unreads
Threads
Huddles...
|
[{"role":"AXPopUpButton","text [{"role":"AXPopUpButton","text":"Switch workspaces… (Jiminny Inc) Has new messages","depth":14,"bounds":{"left":0.51180553,"top":0.08111111,"width":0.025,"height":0.04},"on_screen":true,"role_description":"pop-up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXRadioButton","text":"Home","depth":14,"bounds":{"left":0.50625,"top":0.14,"width":0.036111113,"height":0.075555556},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":true,"is_expanded":false},{"role":"AXStaticText","text":"Home","depth":16,"bounds":{"left":0.5138889,"top":0.19222222,"width":0.020833334,"height":0.015555556},"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"DMs","depth":14,"bounds":{"left":0.50625,"top":0.21555555,"width":0.036111113,"height":0.075555556},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"DMs","depth":16,"bounds":{"left":0.5159722,"top":0.26777777,"width":0.016666668,"height":0.015555556},"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"Activity","depth":14,"bounds":{"left":0.50625,"top":0.2911111,"width":0.036111113,"height":0.075555556},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Activity","depth":16,"bounds":{"left":0.51111114,"top":0.34333333,"width":0.027083334,"height":0.015555556},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.51111114,"top":0.34333333,"width":0.0055555557,"height":0.015555556}},{"char_start":1,"char_count":7,"bounds":{"left":0.5159722,"top":0.34333333,"width":0.022222223,"height":0.015555556}}],"role_description":"text"},{"role":"AXRadioButton","text":"Files","depth":14,"bounds":{"left":0.50625,"top":0.36666667,"width":0.036111113,"height":0.075555556},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Files","depth":16,"bounds":{"left":0.51666665,"top":0.4188889,"width":0.015972223,"height":0.015555556},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.51666665,"top":0.4188889,"width":0.004166667,"height":0.015555556}},{"char_start":1,"char_count":4,"bounds":{"left":0.5208333,"top":0.4188889,"width":0.011805556,"height":0.015555556}}],"role_description":"text"},{"role":"AXRadioButton","text":"Later","depth":14,"bounds":{"left":0.50625,"top":0.4422222,"width":0.036111113,"height":0.075555556},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Later","depth":16,"bounds":{"left":0.5152778,"top":0.49444443,"width":0.018055556,"height":0.015555556},"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"More…","depth":14,"bounds":{"left":0.50625,"top":0.5177778,"width":0.036111113,"height":0.075555556},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"More","depth":16,"bounds":{"left":0.5152778,"top":0.57,"width":0.01875,"height":0.015555556},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Unreads","depth":21,"bounds":{"left":0.57708335,"top":0.12777779,"width":0.039583333,"height":0.0011111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Threads","depth":21,"bounds":{"left":0.57708335,"top":0.12777779,"width":0.036805555,"height":0.0011111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Huddles","depth":21,"bounds":{"left":0.57708335,"top":0.12777779,"width":0.038194444,"height":0.0011111111},"on_screen":true,"role_description":"text"}]...
|
8426715503519048881
|
-8130100538428147665
|
visual_change
|
hybrid
|
NULL
|
Switch workspaces… (Jiminny Inc) Has new messages
Switch workspaces… (Jiminny Inc) Has new messages
Home
Home
DMs
DMs
Activity
Activity
Files
Files
Later
Later
More…
More
Unreads
Threads
Huddles
SlackFileEditViewGoHistoryWindowHelpDOCKERDEV (-zsh)О 882APP (-zsh)tests/Unit/Services/Mail/0ffice/EmailApiClientTest.phptests/Unit/Services/Mail/TextRelayServiceTest.phptests/Unit/Services/RecallAI/RecallAIServiceTest.php79 files changed, 4254 insertions(+), 2020 deletions(-)create mode 100644•github/claude-reviewer/no-ticket-warning.txtcreate mode100644.github/claude-reviewer/scripts/fetch-jira-context.mjsdelete mode100644 app/Component/ES/Processor/BatchStatusInterface.phpcreate mode100644 app/Component/ES/Processor/RedisEntityExtractor.phpdelete mode100644 app/Component/ES/Processor/Traits/WorkerIdTrait.phpcreate mode100644 app/Component/ES/QueuePriorityEnum.phpcreate mode100644 front-end/src/components/DealInsights/Modals/__tests__/AskAnythingModacreate mode 100644 front-end/src/components/shared/AskAnything/__tests__/__snapshots__/Asdelete mode100644 front-end/src/components/shared/AskAnything/__tests__/_snapshots_./Ascreate mode 100644 tests/Unit/Component/ES/Processor/RedisEntityExtractorTest.phpdelete mode 100644 tests/Unit/Component/ES/Processor/Traits/WorkerIdTraitTest.phpcreate mode100644 tests/Unit/Component/ES/QueuePriorityEnumTest.phpcreate mode 100644 tests/Unit/Notifications/Activities/SmsReceivedTest.phpcreate mode100644 tests/Unit/Services/Mail/TextRelayServiceTest.phplukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ co -b JY-20963-fix-imporSwitched to a new branch 'JY-20963-fix-import-on-deleted-entity'lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/Jiminny/app (JY-20963-fix-import-on-deleted-ent|docker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix--config=.php-cs-fixer.dist.phWhat's next:Try Docker Debug forseamless, persistent debugging tools in any container or image →Learn moreat [URL_WITH_CREDENTIALS] ~/jiminny/app (JY-20963-fix-import-on-deleted-ent|docker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.phPHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminskiandcontributors.PHP runtime: 8.3.30Running analysis on 7 cores with 10 files per process.Parallel runner is an experimental feature and may be unstable, use it at your own risk.Loaded config default from".php-cs-fixer.dist.php".5691/5691 [8100%Fixed 0 of 5691 files in 96.077 seconds, 67.00 MB memory usedWhat's next:Try Docker Debug for seamless, persistent debugging tools in anycontainer orimage →Learn moreat https://docs.docker.com/go/debug-cli/lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20963-fix-import-on-deleted-eAPP (...ED→HomeDMsActivityFilesLater...More+Jiminny ...w Starrea& jiminny-x-integrati...8platform-inner-teamChannels# ai-chapter# alerts# backend# bugs# confusion-clinic# curiosity_lab# engineering# general# happy_birthday# jiminny-bg# platform-tickets# product_launches# random# releases# sofia-office# support# thank-yous# the_people_of_jimi...• Direct messages. Galya Dimitrova "o Nikolay YankovR. Aneliya Angelova. Stoyan TanevIomac Grsham, 0labl100% C8•Fri 22 May 13:27:07Describe what you are looking for* & platform-inner-team& 106 0• MessagesP Channel OverviewMore ~+29Lukas NYesterday~добро утро, тази страница не трябва ли да япоказваме само ако няма reportsCleanShot 2026-05-21 at [EMAIL] your questions into ongoing insights+ ALT4 replies Last reply 1 day agoSteliyan Georgiev 3:05 PMМоже ли лайк?https://github.com/jiminny/prophet/pull/519 "Thisis ready to merge from a code quality standpoint."Nikolay Yankov 5:32 PMможе ли 1 approve, че не мога да го тествам безда mergе в мастерhttps://github.com/jiminny/app/pull/12115за github action-aToday ~NewGalya Dimitrova *Y 1:24 PMСторита за рефайнмьнт за вторник:Message & platform-inner-team+...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
70652
|
2536
|
18
|
2026-05-22T10:27:04.157379+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779445624157_m1.jpg...
|
Slack
|
platform-inner-team (Channel) - Jiminny Inc - 2 ne platform-inner-team (Channel) - Jiminny Inc - 2 new items - Slack...
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Switch workspaces… (Jiminny Inc) Has new messages
Switch workspaces… (Jiminny Inc) Has new messages
Home
Home
DMs
DMs
Activity
Activity
Files
Files
Later
Later
More…
More
Unreads
Threads
Huddles
Drafts & sent
1
Directories
jiminny-x-integration-app
platform-inner-team
ai-chapter
alerts
backend
bugs
confusion-clinic
curiosity_lab
engineering
general
happy_birthday
jiminny-bg
platform-tickets
product_launches
random
releases
sofia-office
support
thank-yous
the_people_of_jiminny
Galya Dimitrova
Nikolay Yankov
Aneliya Angelova
Stoyan Tanev
James Graham
Stefka Stoyanova
Vasil Vasilev
Stoyan Tomov
Todor Stamatov
Lukas Kovalik
you
Jira Cloud
Toast
Messages
Messages
Channel Overview
Channel Overview
More
Add and Edit Channel Tabs
Canvas
List
Folder
Jump to date
Lukas Kovalik
Yesterday at 9:44:16 AM
9:44 AM
добро утро, тази страница не трябва ли да я показваме само ако няма reports
CleanShot 2026-05-21 at [EMAIL]
Toggle file
CleanShot 2026-05-21 at [EMAIL]
4 replies
Last reply 1 day ago
View thread
Steliyan Georgiev
Yesterday at 3:05:08 PM
3:05 PM
Може ли лайк?
https://github.com/jiminny/prophet/pull/519
https://github.com/jiminny/prophet/pull/519
"This is ready to merge from a code quality standpoint."
Nikolay Yankov
Yesterday at 5:32:49 PM
5:32 PM
може ли 1 approve, че не мога да го тествам без да merge в мастер
https://github.com/jiminny/app/pull/12115
https://github.com/jiminny/app/pull/12115
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Yesterday at 5:32:57 PM
5:32
за github action-a
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Jump to date
New
Galya Dimitrova
Today at 1:24:02 PM
1:24 PM
Сторита за рефайнмънт за вторник:
https://jiminny.atlassian.net/browse/JY-20834
https://jiminny.atlassian.net/browse/JY-20834
- това го гледахме и преди но вкарах дизайн и по подробно описание и е добре пак да го минем
https://jiminny.atlassian.net/browse/JY-20836
https://jiminny.atlassian.net/browse/JY-20836
https://jiminny.atlassian.net/browse/JY-20813
https://jiminny.atlassian.net/browse/JY-20813
https://jiminny.atlassian.net/browse/JY-20814
https://jiminny.atlassian.net/browse/JY-20814
4 attachments
4 attachments
Jira Cloud Story JY-20834 MCP > Authenticate each user for easy access Story JY-20834 in Jira Cloud Preview in Slack
MCP > Authenticate each user for easy access
Story JY-20834 in Jira Cloud
Preview in Slack
Open in browser
Share Story JY-20834
View conversations
More actions
Jira Cloud Story JY-20836 MCP > Audit log Story JY-20836 in Jira Cloud Preview in Slack
MCP > Audit log
Story JY-20836 in Jira Cloud
Preview in Slack
Open in browser
Share Story JY-20836
View conversations
More actions...
|
[{"role":"AXPopUpButton","text [{"role":"AXPopUpButton","text":"Switch workspaces… (Jiminny Inc) Has new messages","depth":14,"bounds":{"left":0.51180553,"top":0.08111111,"width":0.025,"height":0.04},"on_screen":true,"role_description":"pop-up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXRadioButton","text":"Home","depth":14,"bounds":{"left":0.50625,"top":0.14,"width":0.036111113,"height":0.075555556},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":true,"is_expanded":false},{"role":"AXStaticText","text":"Home","depth":16,"bounds":{"left":0.5138889,"top":0.19222222,"width":0.020833334,"height":0.015555556},"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"DMs","depth":14,"bounds":{"left":0.50625,"top":0.21555555,"width":0.036111113,"height":0.075555556},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"DMs","depth":16,"bounds":{"left":0.5159722,"top":0.26777777,"width":0.016666668,"height":0.015555556},"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"Activity","depth":14,"bounds":{"left":0.50625,"top":0.2911111,"width":0.036111113,"height":0.075555556},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Activity","depth":16,"bounds":{"left":0.51111114,"top":0.34333333,"width":0.027083334,"height":0.015555556},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.51111114,"top":0.34333333,"width":0.0055555557,"height":0.015555556}},{"char_start":1,"char_count":7,"bounds":{"left":0.5159722,"top":0.34333333,"width":0.022222223,"height":0.015555556}}],"role_description":"text"},{"role":"AXRadioButton","text":"Files","depth":14,"bounds":{"left":0.50625,"top":0.36666667,"width":0.036111113,"height":0.075555556},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Files","depth":16,"bounds":{"left":0.51666665,"top":0.4188889,"width":0.015972223,"height":0.015555556},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.51666665,"top":0.4188889,"width":0.004166667,"height":0.015555556}},{"char_start":1,"char_count":4,"bounds":{"left":0.5208333,"top":0.4188889,"width":0.011805556,"height":0.015555556}}],"role_description":"text"},{"role":"AXRadioButton","text":"Later","depth":14,"bounds":{"left":0.50625,"top":0.4422222,"width":0.036111113,"height":0.075555556},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Later","depth":16,"bounds":{"left":0.5152778,"top":0.49444443,"width":0.018055556,"height":0.015555556},"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"More…","depth":14,"bounds":{"left":0.50625,"top":0.5177778,"width":0.036111113,"height":0.075555556},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"More","depth":16,"bounds":{"left":0.5152778,"top":0.57,"width":0.01875,"height":0.015555556},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Unreads","depth":21,"bounds":{"left":0.57708335,"top":0.12777779,"width":0.039583333,"height":0.0011111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Threads","depth":21,"bounds":{"left":0.57708335,"top":0.12777779,"width":0.036805555,"height":0.0011111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Huddles","depth":21,"bounds":{"left":0.57708335,"top":0.12777779,"width":0.038194444,"height":0.0011111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Drafts & sent","depth":21,"bounds":{"left":0.57708335,"top":0.12777779,"width":0.06111111,"height":0.0011111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":21,"bounds":{"left":0.68472224,"top":0.12777779,"width":0.0055555557,"height":0.0011111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Directories","depth":21,"bounds":{"left":0.57708335,"top":0.12777779,"width":0.050694443,"height":0.0011111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"jiminny-x-integration-app","depth":23,"bounds":{"left":0.58819443,"top":0.15111111,"width":0.09166667,"height":0.02},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.58819443,"top":0.15111111,"width":0.0027777778,"height":0.02}},{"char_start":1,"char_count":24,"bounds":{"left":0.59097224,"top":0.15111111,"width":0.11319444,"height":0.02}}],"role_description":"text"},{"role":"AXStaticText","text":"platform-inner-team","depth":23,"bounds":{"left":0.58819443,"top":0.18222222,"width":0.093055554,"height":0.02},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ai-chapter","depth":23,"bounds":{"left":0.58819443,"top":0.25555557,"width":0.046527777,"height":0.02},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"alerts","depth":23,"bounds":{"left":0.58819443,"top":0.28666666,"width":0.025694445,"height":0.02},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.58819443,"top":0.28666666,"width":0.0055555557,"height":0.02}},{"char_start":1,"char_count":5,"bounds":{"left":0.59375,"top":0.28666666,"width":0.019444445,"height":0.02}}],"role_description":"text"},{"role":"AXStaticText","text":"backend","depth":23,"bounds":{"left":0.58819443,"top":0.31777778,"width":0.038194444,"height":0.02},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"bugs","depth":23,"bounds":{"left":0.58819443,"top":0.34888887,"width":0.022222223,"height":0.02},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"confusion-clinic","depth":23,"bounds":{"left":0.58819443,"top":0.38,"width":0.072222225,"height":0.02},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.58819443,"top":0.38,"width":0.0048611113,"height":0.02}},{"char_start":1,"char_count":15,"bounds":{"left":0.59305555,"top":0.38,"width":0.06736111,"height":0.02}}],"role_description":"text"},{"role":"AXStaticText","text":"curiosity_lab","depth":23,"bounds":{"left":0.58819443,"top":0.41111112,"width":0.057638887,"height":0.02},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.58819443,"top":0.41111112,"width":0.0048611113,"height":0.02}},{"char_start":1,"char_count":12,"bounds":{"left":0.59305555,"top":0.41111112,"width":0.05277778,"height":0.02}}],"role_description":"text"},{"role":"AXStaticText","text":"engineering","depth":23,"bounds":{"left":0.58819443,"top":0.4422222,"width":0.054166667,"height":0.02},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"general","depth":23,"bounds":{"left":0.58819443,"top":0.47333333,"width":0.034027778,"height":0.02},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"happy_birthday","depth":23,"bounds":{"left":0.58819443,"top":0.5044444,"width":0.07083333,"height":0.02},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"jiminny-bg","depth":23,"bounds":{"left":0.58819443,"top":0.53555554,"width":0.048611112,"height":0.02},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"platform-tickets","depth":23,"bounds":{"left":0.58819443,"top":0.56666666,"width":0.072916664,"height":0.02},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.58819443,"top":0.56666666,"width":0.00625,"height":0.02}},{"char_start":1,"char_count":15,"bounds":{"left":0.59444445,"top":0.56666666,"width":0.06666667,"height":0.02}}],"role_description":"text"},{"role":"AXStaticText","text":"product_launches","depth":23,"bounds":{"left":0.58819443,"top":0.5977778,"width":0.08055556,"height":0.02},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"random","depth":23,"bounds":{"left":0.58819443,"top":0.6288889,"width":0.035416666,"height":0.02},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"releases","depth":23,"bounds":{"left":0.58819443,"top":0.66,"width":0.038194444,"height":0.02},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"sofia-office","depth":23,"bounds":{"left":0.58819443,"top":0.6911111,"width":0.05138889,"height":0.02},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.58819443,"top":0.6911111,"width":0.0048611113,"height":0.02}},{"char_start":1,"char_count":11,"bounds":{"left":0.59305555,"top":0.6911111,"width":0.045833334,"height":0.02}}],"role_description":"text"},{"role":"AXStaticText","text":"support","depth":23,"bounds":{"left":0.58819443,"top":0.7222222,"width":0.036111113,"height":0.02},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"thank-yous","depth":23,"bounds":{"left":0.58819443,"top":0.75333333,"width":0.05138889,"height":0.02},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"the_people_of_jiminny","depth":23,"bounds":{"left":0.58819443,"top":0.78444445,"width":0.094444446,"height":0.02},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.58819443,"top":0.78444445,"width":0.004166667,"height":0.02}},{"char_start":1,"char_count":20,"bounds":{"left":0.5923611,"top":0.78444445,"width":0.09861111,"height":0.02}}],"role_description":"text"},{"role":"AXStaticText","text":"Galya Dimitrova","depth":23,"bounds":{"left":0.58819443,"top":0.8577778,"width":0.07361111,"height":0.02},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Nikolay Yankov","depth":23,"bounds":{"left":0.58819443,"top":0.8888889,"width":0.06875,"height":0.02},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Aneliya Angelova","depth":23,"bounds":{"left":0.58819443,"top":0.92,"width":0.07847222,"height":0.02},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Stoyan Tanev","depth":23,"bounds":{"left":0.58819443,"top":0.95111114,"width":0.060416665,"height":0.02},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"James Graham","depth":23,"bounds":{"left":0.58819443,"top":0.9822222,"width":0.06666667,"height":0.012222222},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Stefka Stoyanova","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Vasil Vasilev","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Stoyan Tomov","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Todor Stamatov","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Lukas Kovalik","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"you","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Jira Cloud","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Toast","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXRadioButton","text":"Messages","depth":18,"bounds":{"left":0.71319443,"top":0.12777779,"width":0.06458333,"height":0.04222222},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":true,"is_expanded":false},{"role":"AXStaticText","text":"Messages","depth":20,"bounds":{"left":0.7326389,"top":0.14,"width":0.039583333,"height":0.017777778},"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"Channel Overview","depth":18,"bounds":{"left":0.7798611,"top":0.12777779,"width":0.1,"height":0.04222222},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Channel Overview","depth":20,"bounds":{"left":0.79930556,"top":0.14,"width":0.075,"height":0.017777778},"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"More","depth":19,"bounds":{"left":0.8819444,"top":0.12777779,"width":0.04236111,"height":0.04222222},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXPopUpButton","text":"Add and Edit Channel Tabs","depth":18,"bounds":{"left":0.92430556,"top":0.12777779,"width":0.022222223,"height":0.04222222},"on_screen":true,"role_description":"pop-up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Canvas","depth":18,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"List","depth":18,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Folder","depth":18,"on_screen":false,"role_description":"text"},{"role":"AXPopUpButton","text":"Jump to date","depth":23,"bounds":{"left":0.81527776,"top":0.17666666,"width":0.068055555,"height":0.031111112},"on_screen":true,"role_description":"pop-up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Lukas Kovalik","depth":24,"bounds":{"left":0.7465278,"top":0.16111112,"width":0.06458333,"height":0.0011111111},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":24,"bounds":{"left":0.8111111,"top":0.16111112,"width":0.0055555557,"height":0.0011111111},"on_screen":true,"role_description":"text"},{"role":"AXLink","text":"Yesterday at 9:44:16 AM","depth":24,"bounds":{"left":0.81666666,"top":0.16111112,"width":0.031944446,"height":0.0011111111},"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"9:44 AM","depth":25,"bounds":{"left":0.81666666,"top":0.16111112,"width":0.031944446,"height":0.0011111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"добро утро, тази страница не трябва ли да я показваме само ако няма reports","depth":25,"bounds":{"left":0.7465278,"top":0.16111112,"width":0.21527778,"height":0.0011111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"CleanShot 2026-05-21 at 09.43.20@2x.png","depth":25,"bounds":{"left":0.7465278,"top":0.16111112,"width":0.1736111,"height":0.0011111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":25,"bounds":{"left":0.9201389,"top":0.16111112,"width":0.0027777778,"height":0.0011111111},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Toggle file","depth":25,"bounds":{"left":0.92291665,"top":0.16111112,"width":0.013888889,"height":0.0011111111},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":true},{"role":"AXLink","text":"CleanShot 2026-05-21 at 09.43.20@2x.png","depth":27,"bounds":{"left":0.7465278,"top":0.16111112,"width":0.2361111,"height":0.0011111111},"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"4 replies","depth":24,"bounds":{"left":0.80694443,"top":0.16111112,"width":0.034027778,"height":0.0011111111},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Last reply 1 day ago","depth":25,"bounds":{"left":0.84652776,"top":0.16111112,"width":0.07847222,"height":0.0011111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"View thread","depth":25,"bounds":{"left":0.84652776,"top":0.16111112,"width":0.047916666,"height":0.0011111111},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Steliyan Georgiev","depth":24,"bounds":{"left":0.7465278,"top":0.16111112,"width":0.08263889,"height":0.0011111111},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":24,"bounds":{"left":0.82916665,"top":0.16111112,"width":0.0055555557,"height":0.0011111111},"on_screen":true,"role_description":"text"},{"role":"AXLink","text":"Yesterday at 3:05:08 PM","depth":24,"bounds":{"left":0.83402777,"top":0.16111112,"width":0.031944446,"height":0.0011111111},"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"3:05 PM","depth":25,"bounds":{"left":0.83402777,"top":0.16111112,"width":0.031944446,"height":0.0011111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Може ли лайк?","depth":25,"bounds":{"left":0.7465278,"top":0.16111112,"width":0.07361111,"height":0.0011111111},"on_screen":true,"role_description":"text"},{"role":"AXLink","text":"https://github.com/jiminny/prophet/pull/519","depth":25,"bounds":{"left":0.7465278,"top":0.16111112,"width":0.20555556,"height":0.0011111111},"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"https://github.com/jiminny/prophet/pull/519","depth":26,"bounds":{"left":0.7465278,"top":0.16111112,"width":0.20555556,"height":0.0011111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"\"This is ready to merge from a code quality standpoint.\"","depth":25,"bounds":{"left":0.7465278,"top":0.16111112,"width":0.23055555,"height":0.0011111111},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Nikolay Yankov","depth":24,"bounds":{"left":0.7465278,"top":0.16111112,"width":0.072222225,"height":0.0011111111},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":24,"bounds":{"left":0.81805557,"top":0.16111112,"width":0.00625,"height":0.0011111111},"on_screen":true,"role_description":"text"},{"role":"AXLink","text":"Yesterday at 5:32:49 PM","depth":24,"bounds":{"left":0.82361114,"top":0.16111112,"width":0.031944446,"height":0.0011111111},"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"5:32 PM","depth":25,"bounds":{"left":0.82361114,"top":0.16111112,"width":0.031944446,"height":0.0011111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"може ли 1 approve, че не мога да го тествам без да merge в мастер","depth":25,"bounds":{"left":0.7465278,"top":0.16111112,"width":0.23194444,"height":0.0011111111},"on_screen":true,"role_description":"text"},{"role":"AXLink","text":"https://github.com/jiminny/app/pull/12115","depth":25,"bounds":{"left":0.7465278,"top":0.16111112,"width":0.19791667,"height":0.018888889},"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"https://github.com/jiminny/app/pull/12115","depth":26,"bounds":{"left":0.7465278,"top":0.16111112,"width":0.19791667,"height":0.018888889},"on_screen":true,"role_description":"text"},{"role":"AXCheckBox","text":"React with white_check_mark","depth":26,"bounds":{"left":0.8041667,"top":0.16111112,"width":0.022222223,"height":0.0011111111},"on_screen":true,"role_description":"toggle button","subrole":"AXToggleButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"React with eyes","depth":26,"bounds":{"left":0.8263889,"top":0.16111112,"width":0.022222223,"height":0.0011111111},"on_screen":true,"role_description":"toggle button","subrole":"AXToggleButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"React with raised_hands","depth":26,"bounds":{"left":0.8486111,"top":0.16111112,"width":0.022222223,"height":0.0011111111},"on_screen":true,"role_description":"toggle button","subrole":"AXToggleButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Add reaction…","depth":26,"bounds":{"left":0.87083334,"top":0.16111112,"width":0.022222223,"height":0.0011111111},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Reply in thread","depth":26,"bounds":{"left":0.89305556,"top":0.16111112,"width":0.022222223,"height":0.0011111111},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Forward message…","depth":26,"bounds":{"left":0.9152778,"top":0.16111112,"width":0.022222223,"height":0.0011111111},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Save for later","depth":26,"bounds":{"left":0.9375,"top":0.16111112,"width":0.022222223,"height":0.0011111111},"on_screen":true,"role_description":"toggle button","subrole":"AXToggleButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXPopUpButton","text":"More actions","depth":26,"bounds":{"left":0.9597222,"top":0.16111112,"width":0.022222223,"height":0.0011111111},"on_screen":true,"role_description":"pop-up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"Yesterday at 5:32:57 PM","depth":25,"bounds":{"left":0.72430557,"top":0.19666667,"width":0.016666668,"height":0.016666668},"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"5:32","depth":26,"bounds":{"left":0.72430557,"top":0.19666667,"width":0.016666668,"height":0.016666668},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"за github action-a","depth":25,"bounds":{"left":0.7465278,"top":0.19333333,"width":0.08194444,"height":0.02},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.7465278,"top":0.19333333,"width":0.0048611113,"height":0.02}},{"char_start":1,"char_count":17,"bounds":{"left":0.7513889,"top":0.19333333,"width":0.077083334,"height":0.02}}],"role_description":"text"},{"role":"AXCheckBox","text":"React with white_check_mark","depth":26,"bounds":{"left":0.8048611,"top":0.16111112,"width":0.022222223,"height":0.033333335},"on_screen":true,"role_description":"toggle button","subrole":"AXToggleButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"React with eyes","depth":26,"bounds":{"left":0.82708335,"top":0.16111112,"width":0.022222223,"height":0.033333335},"on_screen":true,"role_description":"toggle button","subrole":"AXToggleButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"React with raised_hands","depth":26,"bounds":{"left":0.84930557,"top":0.16111112,"width":0.022222223,"height":0.033333335},"on_screen":true,"role_description":"toggle button","subrole":"AXToggleButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Add reaction…","depth":26,"bounds":{"left":0.8715278,"top":0.16111112,"width":0.022222223,"height":0.033333335},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Reply in thread","depth":26,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Forward message…","depth":26,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Save for later","depth":26,"on_screen":false,"role_description":"toggle button","subrole":"AXToggleButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXPopUpButton","text":"More actions","depth":26,"on_screen":false,"role_description":"pop-up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXPopUpButton","text":"Jump to date","depth":23,"bounds":{"left":0.8229167,"top":0.23555556,"width":0.05277778,"height":0.031111112},"on_screen":true,"role_description":"pop-up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"New","depth":23,"bounds":{"left":0.96458334,"top":0.24111111,"width":0.01875,"height":0.018888889},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Galya Dimitrova","depth":24,"bounds":{"left":0.7465278,"top":0.27888888,"width":0.07638889,"height":0.024444444},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":24,"bounds":{"left":0.8361111,"top":0.28111112,"width":0.0055555557,"height":0.02},"on_screen":true,"role_description":"text"},{"role":"AXLink","text":"Today at 1:24:02 PM","depth":24,"bounds":{"left":0.84166664,"top":0.28444445,"width":0.031944446,"height":0.016666668},"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"1:24 PM","depth":25,"bounds":{"left":0.84166664,"top":0.28444445,"width":0.031944446,"height":0.016666668},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Сторита за рефайнмънт за вторник:","depth":25,"bounds":{"left":0.7465278,"top":0.30555555,"width":0.17291667,"height":0.02},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":27,"bounds":{"left":0.74930555,"top":0.33222222,"width":0.010416667,"height":0.017777778},"on_screen":true,"role_description":"text"},{"role":"AXLink","text":"https://jiminny.atlassian.net/browse/JY-20834","depth":26,"bounds":{"left":0.7659722,"top":0.33,"width":0.21111111,"height":0.02},"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"https://jiminny.atlassian.net/browse/JY-20834","depth":27,"bounds":{"left":0.7659722,"top":0.33,"width":0.21111111,"height":0.02},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"- това го гледахме и преди но вкарах дизайн и по подробно описание и е добре пак да го минем","depth":26,"bounds":{"left":0.7659722,"top":0.35444444,"width":0.21527778,"height":0.06888889},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.7659722,"top":0.35444444,"width":0.004166667,"height":0.02}},{"char_start":1,"char_count":91,"bounds":{"left":0.7659722,"top":0.35444444,"width":0.21527778,"height":0.06888889}}],"role_description":"text"},{"role":"AXStaticText","text":"","depth":27,"bounds":{"left":0.74930555,"top":0.43,"width":0.010416667,"height":0.017777778},"on_screen":true,"role_description":"text"},{"role":"AXLink","text":"https://jiminny.atlassian.net/browse/JY-20836","depth":26,"bounds":{"left":0.7659722,"top":0.42777777,"width":0.21111111,"height":0.02},"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"https://jiminny.atlassian.net/browse/JY-20836","depth":27,"bounds":{"left":0.7659722,"top":0.42777777,"width":0.21111111,"height":0.02},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":27,"bounds":{"left":0.74930555,"top":0.45444444,"width":0.010416667,"height":0.017777778},"on_screen":true,"role_description":"text"},{"role":"AXLink","text":"https://jiminny.atlassian.net/browse/JY-20813","depth":26,"bounds":{"left":0.7659722,"top":0.45222223,"width":0.21111111,"height":0.02},"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"https://jiminny.atlassian.net/browse/JY-20813","depth":27,"bounds":{"left":0.7659722,"top":0.45222223,"width":0.21111111,"height":0.02},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":27,"bounds":{"left":0.74930555,"top":0.4788889,"width":0.010416667,"height":0.017777778},"on_screen":true,"role_description":"text"},{"role":"AXLink","text":"https://jiminny.atlassian.net/browse/JY-20814","depth":26,"bounds":{"left":0.7659722,"top":0.47666666,"width":0.21111111,"height":0.02},"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"https://jiminny.atlassian.net/browse/JY-20814","depth":27,"bounds":{"left":0.7659722,"top":0.47666666,"width":0.21111111,"height":0.02},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"4 attachments","depth":24,"bounds":{"left":0.7465278,"top":0.5088889,"width":0.056944445,"height":0.017777778},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"4 attachments","depth":24,"bounds":{"left":0.8055556,"top":0.5088889,"width":0.011805556,"height":0.017777778},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":true},{"role":"AXButton","text":"Jira Cloud Story JY-20834 MCP > Authenticate each user for easy access Story JY-20834 in Jira Cloud Preview in Slack","depth":26,"bounds":{"left":0.7465278,"top":0.5322222,"width":0.2361111,"height":0.07333333},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"MCP > Authenticate each user for easy access","depth":27,"bounds":{"left":0.7888889,"top":0.5511111,"width":0.18263888,"height":0.04},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.7888889,"top":0.5511111,"width":0.009722223,"height":0.02}},{"char_start":1,"char_count":43,"bounds":{"left":0.7888889,"top":0.5511111,"width":0.18194444,"height":0.02}}],"role_description":"text"},{"role":"AXStaticText","text":"Story JY-20834 in Jira Cloud","depth":28,"bounds":{"left":0.7888889,"top":0.5733333,"width":0.11319444,"height":0.017777778},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.7888889,"top":0.5733333,"width":0.0048611113,"height":0.017777778}},{"char_start":1,"char_count":27,"bounds":{"left":0.79375,"top":0.5733333,"width":0.108333334,"height":0.017777778}}],"role_description":"text"},{"role":"AXStaticText","text":"Preview in Slack","depth":28,"bounds":{"left":0.7888889,"top":0.5733333,"width":0.06527778,"height":0.017777778},"on_screen":true,"role_description":"text"},{"role":"AXLink","text":"Open in browser","depth":28,"bounds":{"left":0.8819444,"top":0.5511111,"width":0.022222223,"height":0.035555556},"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Share Story JY-20834","depth":27,"bounds":{"left":0.90416664,"top":0.5511111,"width":0.022222223,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"View conversations","depth":27,"bounds":{"left":0.92638886,"top":0.5511111,"width":0.022222223,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More actions","depth":27,"bounds":{"left":0.94861114,"top":0.5511111,"width":0.022222223,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Jira Cloud Story JY-20836 MCP > Audit log Story JY-20836 in Jira Cloud Preview in Slack","depth":26,"bounds":{"left":0.7465278,"top":0.61444443,"width":0.2361111,"height":0.07333333},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"MCP > Audit log","depth":27,"bounds":{"left":0.7888889,"top":0.6333333,"width":0.07569444,"height":0.02},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Story JY-20836 in Jira Cloud","depth":28,"bounds":{"left":0.7888889,"top":0.65555555,"width":0.11319444,"height":0.017777778},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.7888889,"top":0.65555555,"width":0.0048611113,"height":0.017777778}},{"char_start":1,"char_count":27,"bounds":{"left":0.79375,"top":0.65555555,"width":0.108333334,"height":0.017777778}}],"role_description":"text"},{"role":"AXStaticText","text":"Preview in Slack","depth":28,"bounds":{"left":0.7888889,"top":0.65555555,"width":0.06527778,"height":0.017777778},"on_screen":true,"role_description":"text"},{"role":"AXLink","text":"Open in browser","depth":28,"bounds":{"left":0.8819444,"top":0.6333333,"width":0.022222223,"height":0.035555556},"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Share Story JY-20836","depth":27,"bounds":{"left":0.90416664,"top":0.6333333,"width":0.022222223,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"View conversations","depth":27,"bounds":{"left":0.92638886,"top":0.6333333,"width":0.022222223,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More actions","depth":27,"bounds":{"left":0.94861114,"top":0.6333333,"width":0.022222223,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
4801508789128446312
|
-4218442651687042698
|
visual_change
|
hybrid
|
NULL
|
Switch workspaces… (Jiminny Inc) Has new messages
Switch workspaces… (Jiminny Inc) Has new messages
Home
Home
DMs
DMs
Activity
Activity
Files
Files
Later
Later
More…
More
Unreads
Threads
Huddles
Drafts & sent
1
Directories
jiminny-x-integration-app
platform-inner-team
ai-chapter
alerts
backend
bugs
confusion-clinic
curiosity_lab
engineering
general
happy_birthday
jiminny-bg
platform-tickets
product_launches
random
releases
sofia-office
support
thank-yous
the_people_of_jiminny
Galya Dimitrova
Nikolay Yankov
Aneliya Angelova
Stoyan Tanev
James Graham
Stefka Stoyanova
Vasil Vasilev
Stoyan Tomov
Todor Stamatov
Lukas Kovalik
you
Jira Cloud
Toast
Messages
Messages
Channel Overview
Channel Overview
More
Add and Edit Channel Tabs
Canvas
List
Folder
Jump to date
Lukas Kovalik
Yesterday at 9:44:16 AM
9:44 AM
добро утро, тази страница не трябва ли да я показваме само ако няма reports
CleanShot 2026-05-21 at [EMAIL]
Toggle file
CleanShot 2026-05-21 at [EMAIL]
4 replies
Last reply 1 day ago
View thread
Steliyan Georgiev
Yesterday at 3:05:08 PM
3:05 PM
Може ли лайк?
https://github.com/jiminny/prophet/pull/519
https://github.com/jiminny/prophet/pull/519
"This is ready to merge from a code quality standpoint."
Nikolay Yankov
Yesterday at 5:32:49 PM
5:32 PM
може ли 1 approve, че не мога да го тествам без да merge в мастер
https://github.com/jiminny/app/pull/12115
https://github.com/jiminny/app/pull/12115
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Yesterday at 5:32:57 PM
5:32
за github action-a
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Jump to date
New
Galya Dimitrova
Today at 1:24:02 PM
1:24 PM
Сторита за рефайнмънт за вторник:
https://jiminny.atlassian.net/browse/JY-20834
https://jiminny.atlassian.net/browse/JY-20834
- това го гледахме и преди но вкарах дизайн и по подробно описание и е добре пак да го минем
https://jiminny.atlassian.net/browse/JY-20836
https://jiminny.atlassian.net/browse/JY-20836
https://jiminny.atlassian.net/browse/JY-20813
https://jiminny.atlassian.net/browse/JY-20813
https://jiminny.atlassian.net/browse/JY-20814
https://jiminny.atlassian.net/browse/JY-20814
4 attachments
4 attachments
Jira Cloud Story JY-20834 MCP > Authenticate each user for easy access Story JY-20834 in Jira Cloud Preview in Slack
MCP > Authenticate each user for easy access
Story JY-20834 in Jira Cloud
Preview in Slack
Open in browser
Share Story JY-20834
View conversations
More actions
Jira Cloud Story JY-20836 MCP > Audit log Story JY-20836 in Jira Cloud Preview in Slack
MCP > Audit log
Story JY-20836 in Jira Cloud
Preview in Slack
Open in browser
Share Story JY-20836
View conversations
More actions
SlackFileEditViewGoHistoryWindowHelpDOCKERDEV (-zsh)O $82APP (-zsh)tests/Unit/Services/Mail/0ffice/EmailApiClientTest.phptests/Unit/Services/Mail/TextRelayServiceTest.phptests/Unit/Services/RecallAI/RecallAIServiceTest.php79 files changed, 4254 insertions(+), 2020 deletions(-)create mode 100644•github/claude-reviewer/no-ticket-warning.txtcreate mode100644.github/claude-reviewer/scripts/fetch-jira-context.mjsdelete mode100644 app/Component/ES/Processor/BatchStatusInterface.phpcreate mode100644 app/Component/ES/Processor/RedisEntityExtractor.phpdelete mode100644 app/Component/ES/Processor/Traits/WorkerIdTrait.phpcreate mode100644 app/Component/ES/QueuePriorityEnum.phpcreate mode100644 front-end/src/components/DealInsights/Modals/__tests__/AskAnythingModacreate mode 100644 front-end/src/components/shared/AskAnything/__tests__/__snapshots__/Asdelete mode100644 front-end/src/components/shared/AskAnything/__tests__/_snapshots_./Ascreate mode 100644 tests/Unit/Component/ES/Processor/RedisEntityExtractorTest.phpdelete mode 100644 tests/Unit/Component/ES/Processor/Traits/WorkerIdTraitTest.phpcreate mode100644 tests/Unit/Component/ES/QueuePriorityEnumTest.phpcreate mode 100644 tests/Unit/Notifications/Activities/SmsReceivedTest.phpcreate mode100644 tests/Unit/Services/Mail/TextRelayServiceTest.phplukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ co -b JY-20963-fix-imporSwitched to a new branch 'JY-20963-fix-import-on-deleted-entity'lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/Jiminny/app (JY-20963-fix-import-on-deleted-ent|docker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix--config=.php-cs-fixer.dist.phWhat's next:Try Docker Debug forseamless, persistent debugging tools in any container or image →Learn moreat [URL_WITH_CREDENTIALS] ~/jiminny/app (JY-20963-fix-import-on-deleted-ent|docker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.phPHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminskiandcontributors.PHP runtime: 8.3.30Running analysis on 7 cores with 10 files per process.Parallel runner is an experimental feature and may be unstable, use it at your own risk.Loaded config default from".php-cs-fixer.dist.php".5691/5691 [8100%Fixed 0 of 5691 files in 96.077 seconds, 67.00 MB memory usedWhat's next:Try Docker Debug for seamless, persistent debugging tools in anycontainer orimageLearn moreat https://docs.docker.com/go/debug-cli/lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20963-fix-import-on-deleted-eAPP (...ED→HomeDMsActivityFilesLater...More+Jiminny ...w Starrea& jiminny-x-integrati...8platform-inner-teamChannels# ai-chapter# alerts# backend# bugs# confusion-clinic# curiosity_lab# engineering# general# happy_birthday# jiminny-bg# platform-tickets# product_launches# random# releases# sofia-office# support# thank-yous# the_people_of_jimi...• Direct messages. Galya Dimitrova "o Nikolay YankovR. Aneliya Angelova. Stoyan TanevIomac Grshamlahl100% <478•Fri 22 May 13:27:04Describe what you are looking for* & platform-inner-team& 10MessagesP Channel OverviewMore ~+8inuv.com/muuvzapp/pum 1z119Yesterday~за github action -Today~NewGalya Dimit.Сторита за рефайнмьнт за вторник:• https://jiminny.atlassian.net/browse/JY-20834- това го гледахме и преди но вкарах дизайни по подробно описание и е добре пак да гоминем• https://jiminny.atlassian.net/browse/JY-20836http://iminyatassan.net/browse/Y/208134 attachments •MCP > Authenticate each user for eas...Story JY-20834 in Jira CloudMCP > Audit logStory JY-20836 in Jira CloudTwilio number is not released when V...T' Bug JY-20813 in Jira CloudRelease unused Twilio numbers on Pr...# Story JY-20814 in Jira CloudMessage & platform-inner-team+...
|
70651
|
NULL
|
NULL
|
NULL
|
|
70651
|
2536
|
17
|
2026-05-22T10:27:00.978383+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779445620978_m1.jpg...
|
Slack
|
platform-inner-team (Channel) - Jiminny Inc - 2 ne platform-inner-team (Channel) - Jiminny Inc - 2 new items - Slack...
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Switch workspaces… (Jiminny Inc) Has new messages
Switch workspaces… (Jiminny Inc) Has new messages
Home
Home
DMs
DMs
Activity
Activity
Files
Files
Later
Later
More…
More
Unreads
Threads
Huddles
Drafts & sent
1
Directories
jiminny-x-integration-app
platform-inner-team
ai-chapter
alerts
backend
bugs
confusion-clinic
curiosity_lab
engineering
general
happy_birthday
jiminny-bg
platform-tickets
product_launches
random
releases
sofia-office
support
thank-yous
the_people_of_jiminny
Galya Dimitrova
Nikolay Yankov
Aneliya Angelova
Stoyan Tanev
James Graham
Stefka Stoyanova
Vasil Vasilev
Stoyan Tomov
Todor Stamatov
Lukas Kovalik
you
Jira Cloud
Toast
Messages
Messages
Channel Overview
Channel Overview
More
Add and Edit Channel Tabs
Canvas
List
Folder
Jump to date
Lukas Kovalik
Yesterday at 9:44:16 AM
9:44 AM
добро утро, тази страница не трябва ли да я показваме само ако няма reports
CleanShot 2026-05-21 at [EMAIL]
Toggle file
CleanShot 2026-05-21 at [EMAIL]
4 replies
Last reply 1 day ago
View thread
Steliyan Georgiev
Yesterday at 3:05:08 PM
3:05 PM
Може ли лайк?
https://github.com/jiminny/prophet/pull/519
https://github.com/jiminny/prophet/pull/519
"This is ready to merge from a code quality standpoint."
Nikolay Yankov
Yesterday at 5:32:49 PM
5:32 PM
може ли 1 approve, че не мога да го тествам без да merge в мастер
https://github.com/jiminny/app/pull/12115
https://github.com/jiminny/app/pull/12115
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Yesterday at 5:32:57 PM
5:32
за github action-a
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Jump to date
New
Galya Dimitrova
Today at 1:24:02 PM
1:24 PM
Сторита за рефайнмънт за вторник:
https://jiminny.atlassian.net/browse/JY-20834
https://jiminny.atlassian.net/browse/JY-20834
- това го гледахме и преди но вкарах дизайн и по подробно описание и е добре пак да го минем
https://jiminny.atlassian.net/browse/JY-20836
https://jiminny.atlassian.net/browse/JY-20836
https://jiminny.atlassian.net/browse/JY-20813
https://jiminny.atlassian.net/browse/JY-20813
https://jiminny.atlassian.net/browse/JY-20814
https://jiminny.atlassian.net/browse/JY-20814
4 attachments
4 attachments
Jira Cloud Story JY-20834 MCP > Authenticate each user for easy access Story JY-20834 in Jira Cloud Preview in Slack
MCP > Authenticate each user for easy access
Story JY-20834 in Jira Cloud
Preview in Slack
Open in browser
Share Story JY-20834
View conversations
More actions
Jira Cloud Story JY-20836 MCP > Audit log Story JY-20836 in Jira Cloud Preview in Slack
MCP > Audit log
Story JY-20836 in Jira Cloud
Preview in Slack
Open in browser
Share Story JY-20836
View conversations
More actions
Jira Cloud Bug JY-20813 Twilio number is not released when Voice role is removed from user Bug JY-20813 in Jira Cloud Preview in Slack
Twilio number is not released when Voice role is removed from user
Bug JY-20813 in Jira Cloud
Preview in Slack
Open in browser
Share Bug JY-20813
View conversations
More actions
Jira Cloud Story JY-20814 Release unused Twilio numbers on Production Story JY-20814 in Jira Cloud Preview in Slack
Release unused Twilio numbers on Production
Story JY-20814 in Jira Cloud
Preview in Slack
Open in browser
Share Story JY-20814
View conversations
More actions
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Processing uploaded file… complete! Message ready to be sent.
Channel platform-inner-team...
|
[{"role":"AXPopUpButton","text [{"role":"AXPopUpButton","text":"Switch workspaces… (Jiminny Inc) Has new messages","depth":14,"bounds":{"left":0.51180553,"top":0.08111111,"width":0.025,"height":0.04},"on_screen":true,"role_description":"pop-up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXRadioButton","text":"Home","depth":14,"bounds":{"left":0.50625,"top":0.14,"width":0.036111113,"height":0.075555556},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":true,"is_expanded":false},{"role":"AXStaticText","text":"Home","depth":16,"bounds":{"left":0.5138889,"top":0.19222222,"width":0.020833334,"height":0.015555556},"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"DMs","depth":14,"bounds":{"left":0.50625,"top":0.21555555,"width":0.036111113,"height":0.075555556},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"DMs","depth":16,"bounds":{"left":0.5159722,"top":0.26777777,"width":0.016666668,"height":0.015555556},"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"Activity","depth":14,"bounds":{"left":0.50625,"top":0.2911111,"width":0.036111113,"height":0.075555556},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Activity","depth":16,"bounds":{"left":0.51111114,"top":0.34333333,"width":0.027083334,"height":0.015555556},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.51111114,"top":0.34333333,"width":0.0055555557,"height":0.015555556}},{"char_start":1,"char_count":7,"bounds":{"left":0.5159722,"top":0.34333333,"width":0.022222223,"height":0.015555556}}],"role_description":"text"},{"role":"AXRadioButton","text":"Files","depth":14,"bounds":{"left":0.50625,"top":0.36666667,"width":0.036111113,"height":0.075555556},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Files","depth":16,"bounds":{"left":0.51666665,"top":0.4188889,"width":0.015972223,"height":0.015555556},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.51666665,"top":0.4188889,"width":0.004166667,"height":0.015555556}},{"char_start":1,"char_count":4,"bounds":{"left":0.5208333,"top":0.4188889,"width":0.011805556,"height":0.015555556}}],"role_description":"text"},{"role":"AXRadioButton","text":"Later","depth":14,"bounds":{"left":0.50625,"top":0.4422222,"width":0.036111113,"height":0.075555556},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Later","depth":16,"bounds":{"left":0.5152778,"top":0.49444443,"width":0.018055556,"height":0.015555556},"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"More…","depth":14,"bounds":{"left":0.50625,"top":0.5177778,"width":0.036111113,"height":0.075555556},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"More","depth":16,"bounds":{"left":0.5152778,"top":0.57,"width":0.01875,"height":0.015555556},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Unreads","depth":21,"bounds":{"left":0.57708335,"top":0.12777779,"width":0.039583333,"height":0.0011111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Threads","depth":21,"bounds":{"left":0.57708335,"top":0.12777779,"width":0.036805555,"height":0.0011111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Huddles","depth":21,"bounds":{"left":0.57708335,"top":0.12777779,"width":0.038194444,"height":0.0011111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Drafts & sent","depth":21,"bounds":{"left":0.57708335,"top":0.12777779,"width":0.06111111,"height":0.0011111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":21,"bounds":{"left":0.68472224,"top":0.12777779,"width":0.0055555557,"height":0.0011111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Directories","depth":21,"bounds":{"left":0.57708335,"top":0.12777779,"width":0.050694443,"height":0.0011111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"jiminny-x-integration-app","depth":23,"bounds":{"left":0.58819443,"top":0.15111111,"width":0.09166667,"height":0.02},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.58819443,"top":0.15111111,"width":0.0027777778,"height":0.02}},{"char_start":1,"char_count":24,"bounds":{"left":0.59097224,"top":0.15111111,"width":0.11319444,"height":0.02}}],"role_description":"text"},{"role":"AXStaticText","text":"platform-inner-team","depth":24,"bounds":{"left":0.58819443,"top":0.18222222,"width":0.093055554,"height":0.02},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ai-chapter","depth":23,"bounds":{"left":0.58819443,"top":0.25555557,"width":0.046527777,"height":0.02},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"alerts","depth":23,"bounds":{"left":0.58819443,"top":0.28666666,"width":0.025694445,"height":0.02},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.58819443,"top":0.28666666,"width":0.0055555557,"height":0.02}},{"char_start":1,"char_count":5,"bounds":{"left":0.59375,"top":0.28666666,"width":0.019444445,"height":0.02}}],"role_description":"text"},{"role":"AXStaticText","text":"backend","depth":23,"bounds":{"left":0.58819443,"top":0.31777778,"width":0.038194444,"height":0.02},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"bugs","depth":23,"bounds":{"left":0.58819443,"top":0.34888887,"width":0.022222223,"height":0.02},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"confusion-clinic","depth":23,"bounds":{"left":0.58819443,"top":0.38,"width":0.072222225,"height":0.02},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.58819443,"top":0.38,"width":0.0048611113,"height":0.02}},{"char_start":1,"char_count":15,"bounds":{"left":0.59305555,"top":0.38,"width":0.06736111,"height":0.02}}],"role_description":"text"},{"role":"AXStaticText","text":"curiosity_lab","depth":23,"bounds":{"left":0.58819443,"top":0.41111112,"width":0.057638887,"height":0.02},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.58819443,"top":0.41111112,"width":0.0048611113,"height":0.02}},{"char_start":1,"char_count":12,"bounds":{"left":0.59305555,"top":0.41111112,"width":0.05277778,"height":0.02}}],"role_description":"text"},{"role":"AXStaticText","text":"engineering","depth":23,"bounds":{"left":0.58819443,"top":0.4422222,"width":0.054166667,"height":0.02},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"general","depth":23,"bounds":{"left":0.58819443,"top":0.47333333,"width":0.034027778,"height":0.02},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"happy_birthday","depth":23,"bounds":{"left":0.58819443,"top":0.5044444,"width":0.07083333,"height":0.02},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"jiminny-bg","depth":23,"bounds":{"left":0.58819443,"top":0.53555554,"width":0.048611112,"height":0.02},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"platform-tickets","depth":23,"bounds":{"left":0.58819443,"top":0.56666666,"width":0.072916664,"height":0.02},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.58819443,"top":0.56666666,"width":0.00625,"height":0.02}},{"char_start":1,"char_count":15,"bounds":{"left":0.59444445,"top":0.56666666,"width":0.06666667,"height":0.02}}],"role_description":"text"},{"role":"AXStaticText","text":"product_launches","depth":23,"bounds":{"left":0.58819443,"top":0.5977778,"width":0.08055556,"height":0.02},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"random","depth":23,"bounds":{"left":0.58819443,"top":0.6288889,"width":0.035416666,"height":0.02},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"releases","depth":23,"bounds":{"left":0.58819443,"top":0.66,"width":0.038194444,"height":0.02},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"sofia-office","depth":23,"bounds":{"left":0.58819443,"top":0.6911111,"width":0.05138889,"height":0.02},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.58819443,"top":0.6911111,"width":0.0048611113,"height":0.02}},{"char_start":1,"char_count":11,"bounds":{"left":0.59305555,"top":0.6911111,"width":0.045833334,"height":0.02}}],"role_description":"text"},{"role":"AXStaticText","text":"support","depth":23,"bounds":{"left":0.58819443,"top":0.7222222,"width":0.036111113,"height":0.02},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"thank-yous","depth":23,"bounds":{"left":0.58819443,"top":0.75333333,"width":0.05138889,"height":0.02},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"the_people_of_jiminny","depth":23,"bounds":{"left":0.58819443,"top":0.78444445,"width":0.094444446,"height":0.02},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.58819443,"top":0.78444445,"width":0.004166667,"height":0.02}},{"char_start":1,"char_count":20,"bounds":{"left":0.5923611,"top":0.78444445,"width":0.09861111,"height":0.02}}],"role_description":"text"},{"role":"AXStaticText","text":"Galya Dimitrova","depth":23,"bounds":{"left":0.58819443,"top":0.8577778,"width":0.07361111,"height":0.02},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Nikolay Yankov","depth":23,"bounds":{"left":0.58819443,"top":0.8888889,"width":0.06875,"height":0.02},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Aneliya Angelova","depth":23,"bounds":{"left":0.58819443,"top":0.92,"width":0.07847222,"height":0.02},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Stoyan Tanev","depth":23,"bounds":{"left":0.58819443,"top":0.95111114,"width":0.060416665,"height":0.02},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"James Graham","depth":23,"bounds":{"left":0.58819443,"top":0.9822222,"width":0.06666667,"height":0.012222222},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Stefka Stoyanova","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Vasil Vasilev","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Stoyan Tomov","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Todor Stamatov","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Lukas Kovalik","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"you","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Jira Cloud","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Toast","depth":23,"on_screen":false,"role_description":"text"},{"role":"AXRadioButton","text":"Messages","depth":18,"bounds":{"left":0.71319443,"top":0.12777779,"width":0.06458333,"height":0.04222222},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":true,"is_expanded":false},{"role":"AXStaticText","text":"Messages","depth":20,"bounds":{"left":0.7326389,"top":0.14,"width":0.039583333,"height":0.017777778},"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"Channel Overview","depth":18,"bounds":{"left":0.7798611,"top":0.12777779,"width":0.1,"height":0.04222222},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Channel Overview","depth":20,"bounds":{"left":0.79930556,"top":0.14,"width":0.075,"height":0.017777778},"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"More","depth":19,"bounds":{"left":0.8819444,"top":0.12777779,"width":0.04236111,"height":0.04222222},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXPopUpButton","text":"Add and Edit Channel Tabs","depth":18,"bounds":{"left":0.92430556,"top":0.12777779,"width":0.022222223,"height":0.04222222},"on_screen":true,"role_description":"pop-up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Canvas","depth":18,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"List","depth":18,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Folder","depth":18,"on_screen":false,"role_description":"text"},{"role":"AXPopUpButton","text":"Jump to date","depth":24,"bounds":{"left":0.81527776,"top":0.17666666,"width":0.068055555,"height":0.031111112},"on_screen":true,"role_description":"pop-up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Lukas Kovalik","depth":25,"bounds":{"left":0.7465278,"top":0.16111112,"width":0.06458333,"height":0.0011111111},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":25,"bounds":{"left":0.8111111,"top":0.16111112,"width":0.0055555557,"height":0.0011111111},"on_screen":true,"role_description":"text"},{"role":"AXLink","text":"Yesterday at 9:44:16 AM","depth":25,"bounds":{"left":0.81666666,"top":0.16111112,"width":0.031944446,"height":0.0011111111},"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"9:44 AM","depth":26,"bounds":{"left":0.81666666,"top":0.16111112,"width":0.031944446,"height":0.0011111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"добро утро, тази страница не трябва ли да я показваме само ако няма reports","depth":26,"bounds":{"left":0.7465278,"top":0.16111112,"width":0.21527778,"height":0.0011111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"CleanShot 2026-05-21 at 09.43.20@2x.png","depth":26,"bounds":{"left":0.7465278,"top":0.16111112,"width":0.1736111,"height":0.0011111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":26,"bounds":{"left":0.9201389,"top":0.16111112,"width":0.0027777778,"height":0.0011111111},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Toggle file","depth":26,"bounds":{"left":0.92291665,"top":0.16111112,"width":0.013888889,"height":0.0011111111},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":true},{"role":"AXLink","text":"CleanShot 2026-05-21 at 09.43.20@2x.png","depth":28,"bounds":{"left":0.7465278,"top":0.16111112,"width":0.2361111,"height":0.0011111111},"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"4 replies","depth":25,"bounds":{"left":0.80694443,"top":0.16111112,"width":0.034027778,"height":0.0011111111},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Last reply 1 day ago","depth":26,"bounds":{"left":0.84652776,"top":0.16111112,"width":0.07847222,"height":0.0011111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"View thread","depth":26,"bounds":{"left":0.84652776,"top":0.16111112,"width":0.047916666,"height":0.0011111111},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Steliyan Georgiev","depth":25,"bounds":{"left":0.7465278,"top":0.16111112,"width":0.08263889,"height":0.0011111111},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":25,"bounds":{"left":0.82916665,"top":0.16111112,"width":0.0055555557,"height":0.0011111111},"on_screen":true,"role_description":"text"},{"role":"AXLink","text":"Yesterday at 3:05:08 PM","depth":25,"bounds":{"left":0.83402777,"top":0.16111112,"width":0.031944446,"height":0.0011111111},"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"3:05 PM","depth":26,"bounds":{"left":0.83402777,"top":0.16111112,"width":0.031944446,"height":0.0011111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Може ли лайк?","depth":26,"bounds":{"left":0.7465278,"top":0.16111112,"width":0.07361111,"height":0.015555556},"on_screen":true,"role_description":"text"},{"role":"AXLink","text":"https://github.com/jiminny/prophet/pull/519","depth":26,"bounds":{"left":0.7465278,"top":0.18111111,"width":0.20555556,"height":0.02},"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"https://github.com/jiminny/prophet/pull/519","depth":27,"bounds":{"left":0.7465278,"top":0.18111111,"width":0.20555556,"height":0.02},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"\"This is ready to merge from a code quality standpoint.\"","depth":26,"bounds":{"left":0.7465278,"top":0.18111111,"width":0.23055555,"height":0.044444446},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.9513889,"top":0.18111111,"width":0.0034722222,"height":0.02}},{"char_start":1,"char_count":55,"bounds":{"left":0.7465278,"top":0.18111111,"width":0.23055555,"height":0.044444446}}],"role_description":"text"},{"role":"AXButton","text":"Nikolay Yankov","depth":25,"bounds":{"left":0.7465278,"top":0.23666666,"width":0.072222225,"height":0.024444444},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":25,"bounds":{"left":0.81805557,"top":0.23888889,"width":0.00625,"height":0.02},"on_screen":true,"role_description":"text"},{"role":"AXLink","text":"Yesterday at 5:32:49 PM","depth":25,"bounds":{"left":0.82361114,"top":0.24222222,"width":0.031944446,"height":0.016666668},"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"5:32 PM","depth":26,"bounds":{"left":0.82361114,"top":0.24222222,"width":0.031944446,"height":0.016666668},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"може ли 1 approve, че не мога да го тествам без да merge в мастер","depth":26,"bounds":{"left":0.7465278,"top":0.26333332,"width":0.23194444,"height":0.044444446},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.7465278,"top":0.26333332,"width":0.007638889,"height":0.02}},{"char_start":1,"char_count":64,"bounds":{"left":0.7465278,"top":0.26333332,"width":0.23194444,"height":0.044444446}}],"role_description":"text"},{"role":"AXLink","text":"https://github.com/jiminny/app/pull/12115","depth":26,"bounds":{"left":0.7465278,"top":0.3122222,"width":0.19791667,"height":0.02},"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"https://github.com/jiminny/app/pull/12115","depth":27,"bounds":{"left":0.7465278,"top":0.3122222,"width":0.19791667,"height":0.02},"on_screen":true,"role_description":"text"},{"role":"AXCheckBox","text":"React with white_check_mark","depth":27,"bounds":{"left":0.8041667,"top":0.2188889,"width":0.022222223,"height":0.035555556},"on_screen":true,"role_description":"toggle button","subrole":"AXToggleButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"React with eyes","depth":27,"bounds":{"left":0.8263889,"top":0.2188889,"width":0.022222223,"height":0.035555556},"on_screen":true,"role_description":"toggle button","subrole":"AXToggleButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"React with raised_hands","depth":27,"bounds":{"left":0.8486111,"top":0.2188889,"width":0.022222223,"height":0.035555556},"on_screen":true,"role_description":"toggle button","subrole":"AXToggleButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Add reaction…","depth":27,"bounds":{"left":0.87083334,"top":0.2188889,"width":0.022222223,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Reply in thread","depth":27,"bounds":{"left":0.89305556,"top":0.2188889,"width":0.022222223,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Forward message…","depth":27,"bounds":{"left":0.9152778,"top":0.2188889,"width":0.022222223,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Save for later","depth":27,"bounds":{"left":0.9375,"top":0.2188889,"width":0.022222223,"height":0.035555556},"on_screen":true,"role_description":"toggle button","subrole":"AXToggleButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXPopUpButton","text":"More actions","depth":27,"bounds":{"left":0.9597222,"top":0.2188889,"width":0.022222223,"height":0.035555556},"on_screen":true,"role_description":"pop-up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"Yesterday at 5:32:57 PM","depth":26,"bounds":{"left":0.72430557,"top":0.34888887,"width":0.016666668,"height":0.016666668},"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"5:32","depth":27,"bounds":{"left":0.72430557,"top":0.34888887,"width":0.016666668,"height":0.016666668},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"за github action-a","depth":26,"bounds":{"left":0.7465278,"top":0.34555554,"width":0.08194444,"height":0.02},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.7465278,"top":0.34555554,"width":0.0048611113,"height":0.02}},{"char_start":1,"char_count":17,"bounds":{"left":0.7513889,"top":0.34555554,"width":0.077083334,"height":0.02}}],"role_description":"text"},{"role":"AXCheckBox","text":"React with white_check_mark","depth":27,"bounds":{"left":0.8048611,"top":0.31111112,"width":0.022222223,"height":0.035555556},"on_screen":true,"role_description":"toggle button","subrole":"AXToggleButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"React with eyes","depth":27,"bounds":{"left":0.82708335,"top":0.31111112,"width":0.022222223,"height":0.035555556},"on_screen":true,"role_description":"toggle button","subrole":"AXToggleButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"React with raised_hands","depth":27,"bounds":{"left":0.84930557,"top":0.31111112,"width":0.022222223,"height":0.035555556},"on_screen":true,"role_description":"toggle button","subrole":"AXToggleButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Add reaction…","depth":27,"bounds":{"left":0.8715278,"top":0.31111112,"width":0.022222223,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Reply in thread","depth":27,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Forward message…","depth":27,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Save for later","depth":27,"on_screen":false,"role_description":"toggle button","subrole":"AXToggleButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXPopUpButton","text":"More actions","depth":27,"on_screen":false,"role_description":"pop-up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXPopUpButton","text":"Jump to date","depth":24,"bounds":{"left":0.8229167,"top":0.38777778,"width":0.05277778,"height":0.031111112},"on_screen":true,"role_description":"pop-up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"New","depth":24,"bounds":{"left":0.96458334,"top":0.39333335,"width":0.01875,"height":0.018888889},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Galya Dimitrova","depth":25,"bounds":{"left":0.7465278,"top":0.4311111,"width":0.07638889,"height":0.024444444},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"","depth":25,"bounds":{"left":0.8361111,"top":0.43333334,"width":0.0055555557,"height":0.02},"on_screen":true,"role_description":"text"},{"role":"AXLink","text":"Today at 1:24:02 PM","depth":25,"bounds":{"left":0.84166664,"top":0.43666667,"width":0.031944446,"height":0.016666668},"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"1:24 PM","depth":26,"bounds":{"left":0.84166664,"top":0.43666667,"width":0.031944446,"height":0.016666668},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Сторита за рефайнмънт за вторник:","depth":26,"bounds":{"left":0.7465278,"top":0.45777777,"width":0.17291667,"height":0.02},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"bounds":{"left":0.74930555,"top":0.48444444,"width":0.010416667,"height":0.017777778},"on_screen":true,"role_description":"text"},{"role":"AXLink","text":"https://jiminny.atlassian.net/browse/JY-20834","depth":27,"bounds":{"left":0.7659722,"top":0.48222223,"width":0.21111111,"height":0.02},"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"https://jiminny.atlassian.net/browse/JY-20834","depth":28,"bounds":{"left":0.7659722,"top":0.48222223,"width":0.21111111,"height":0.02},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"- това го гледахме и преди но вкарах дизайн и по подробно описание и е добре пак да го минем","depth":27,"bounds":{"left":0.7659722,"top":0.50666666,"width":0.21527778,"height":0.06888889},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.7659722,"top":0.50666666,"width":0.004166667,"height":0.02}},{"char_start":1,"char_count":91,"bounds":{"left":0.7659722,"top":0.50666666,"width":0.21527778,"height":0.06888889}}],"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"bounds":{"left":0.74930555,"top":0.5822222,"width":0.010416667,"height":0.017777778},"on_screen":true,"role_description":"text"},{"role":"AXLink","text":"https://jiminny.atlassian.net/browse/JY-20836","depth":27,"bounds":{"left":0.7659722,"top":0.58,"width":0.21111111,"height":0.02},"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"https://jiminny.atlassian.net/browse/JY-20836","depth":28,"bounds":{"left":0.7659722,"top":0.58,"width":0.21111111,"height":0.02},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"bounds":{"left":0.74930555,"top":0.6066667,"width":0.010416667,"height":0.017777778},"on_screen":true,"role_description":"text"},{"role":"AXLink","text":"https://jiminny.atlassian.net/browse/JY-20813","depth":27,"bounds":{"left":0.7659722,"top":0.60444444,"width":0.21111111,"height":0.02},"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"https://jiminny.atlassian.net/browse/JY-20813","depth":28,"bounds":{"left":0.7659722,"top":0.60444444,"width":0.21111111,"height":0.02},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":28,"bounds":{"left":0.74930555,"top":0.6311111,"width":0.010416667,"height":0.017777778},"on_screen":true,"role_description":"text"},{"role":"AXLink","text":"https://jiminny.atlassian.net/browse/JY-20814","depth":27,"bounds":{"left":0.7659722,"top":0.6288889,"width":0.21111111,"height":0.02},"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"https://jiminny.atlassian.net/browse/JY-20814","depth":28,"bounds":{"left":0.7659722,"top":0.6288889,"width":0.21111111,"height":0.02},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"4 attachments","depth":25,"bounds":{"left":0.7465278,"top":0.6611111,"width":0.056944445,"height":0.017777778},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"4 attachments","depth":25,"bounds":{"left":0.8055556,"top":0.6611111,"width":0.011805556,"height":0.017777778},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":true},{"role":"AXButton","text":"Jira Cloud Story JY-20834 MCP > Authenticate each user for easy access Story JY-20834 in Jira Cloud Preview in Slack","depth":27,"bounds":{"left":0.7465278,"top":0.6844444,"width":0.2361111,"height":0.07333333},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"MCP > Authenticate each user for easy access","depth":28,"bounds":{"left":0.7888889,"top":0.7033333,"width":0.18263888,"height":0.04},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.7888889,"top":0.7033333,"width":0.009722223,"height":0.02}},{"char_start":1,"char_count":43,"bounds":{"left":0.7888889,"top":0.7033333,"width":0.18194444,"height":0.02}}],"role_description":"text"},{"role":"AXStaticText","text":"Story JY-20834 in Jira Cloud","depth":29,"bounds":{"left":0.7888889,"top":0.72555554,"width":0.11319444,"height":0.017777778},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.7888889,"top":0.72555554,"width":0.0048611113,"height":0.017777778}},{"char_start":1,"char_count":27,"bounds":{"left":0.79375,"top":0.72555554,"width":0.108333334,"height":0.017777778}}],"role_description":"text"},{"role":"AXStaticText","text":"Preview in Slack","depth":29,"bounds":{"left":0.7888889,"top":0.72555554,"width":0.06527778,"height":0.017777778},"on_screen":true,"role_description":"text"},{"role":"AXLink","text":"Open in browser","depth":29,"bounds":{"left":0.8819444,"top":0.7033333,"width":0.022222223,"height":0.035555556},"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Share Story JY-20834","depth":28,"bounds":{"left":0.90416664,"top":0.7033333,"width":0.022222223,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"View conversations","depth":28,"bounds":{"left":0.92638886,"top":0.7033333,"width":0.022222223,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More actions","depth":28,"bounds":{"left":0.94861114,"top":0.7033333,"width":0.022222223,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Jira Cloud Story JY-20836 MCP > Audit log Story JY-20836 in Jira Cloud Preview in Slack","depth":27,"bounds":{"left":0.7465278,"top":0.76666665,"width":0.2361111,"height":0.07333333},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"MCP > Audit log","depth":28,"bounds":{"left":0.7888889,"top":0.78555554,"width":0.07569444,"height":0.02},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Story JY-20836 in Jira Cloud","depth":29,"bounds":{"left":0.7888889,"top":0.80777776,"width":0.11319444,"height":0.017777778},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.7888889,"top":0.80777776,"width":0.0048611113,"height":0.017777778}},{"char_start":1,"char_count":27,"bounds":{"left":0.79375,"top":0.80777776,"width":0.108333334,"height":0.017777778}}],"role_description":"text"},{"role":"AXStaticText","text":"Preview in Slack","depth":29,"bounds":{"left":0.7888889,"top":0.80777776,"width":0.06527778,"height":0.017777778},"on_screen":true,"role_description":"text"},{"role":"AXLink","text":"Open in browser","depth":29,"bounds":{"left":0.8819444,"top":0.78555554,"width":0.022222223,"height":0.035555556},"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Share Story JY-20836","depth":28,"bounds":{"left":0.90416664,"top":0.78555554,"width":0.022222223,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"View conversations","depth":28,"bounds":{"left":0.92638886,"top":0.78555554,"width":0.022222223,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More actions","depth":28,"bounds":{"left":0.94861114,"top":0.78555554,"width":0.022222223,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Jira Cloud Bug JY-20813 Twilio number is not released when Voice role is removed from user Bug JY-20813 in Jira Cloud Preview in Slack","depth":27,"bounds":{"left":0.7465278,"top":0.8488889,"width":0.2361111,"height":0.03888889},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Twilio number is not released when Voice role is removed from user","depth":28,"bounds":{"left":0.7888889,"top":0.86777776,"width":0.17986111,"height":0.02},"on_screen":true,"lines":[{"char_start":0,"char_count":1,"bounds":{"left":0.7888889,"top":0.86777776,"width":0.0055555557,"height":0.02}},{"char_start":1,"char_count":65,"bounds":{"left":0.7888889,"top":0.86777776,"width":0.18055555,"height":0.02}}],"role_description":"text"},{"role":"AXStaticText","text":"Bug JY-20813 in Jira Cloud","depth":29,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Preview in Slack","depth":29,"on_screen":false,"role_description":"text"},{"role":"AXLink","text":"Open in browser","depth":29,"bounds":{"left":0.8819444,"top":0.86777776,"width":0.022222223,"height":0.02},"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Share Bug JY-20813","depth":28,"bounds":{"left":0.90416664,"top":0.86777776,"width":0.022222223,"height":0.02},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"View conversations","depth":28,"bounds":{"left":0.92638886,"top":0.86777776,"width":0.022222223,"height":0.02},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More actions","depth":28,"bounds":{"left":0.94861114,"top":0.86777776,"width":0.022222223,"height":0.02},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Jira Cloud Story JY-20814 Release unused Twilio numbers on Production Story JY-20814 in Jira Cloud Preview in Slack","depth":27,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Release unused Twilio numbers on Production","depth":28,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Story JY-20814 in Jira Cloud","depth":29,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Preview in Slack","depth":29,"on_screen":false,"role_description":"text"},{"role":"AXLink","text":"Open in browser","depth":29,"on_screen":false,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Share Story JY-20814","depth":28,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"View conversations","depth":28,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More actions","depth":28,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"React with white_check_mark","depth":27,"bounds":{"left":0.8048611,"top":0.41222224,"width":0.022222223,"height":0.035555556},"on_screen":true,"role_description":"toggle button","subrole":"AXToggleButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"React with eyes","depth":27,"bounds":{"left":0.82708335,"top":0.41222224,"width":0.022222223,"height":0.035555556},"on_screen":true,"role_description":"toggle button","subrole":"AXToggleButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"React with raised_hands","depth":27,"bounds":{"left":0.84930557,"top":0.41222224,"width":0.022222223,"height":0.035555556},"on_screen":true,"role_description":"toggle button","subrole":"AXToggleButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Add reaction…","depth":27,"bounds":{"left":0.8715278,"top":0.41222224,"width":0.022222223,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Reply in thread","depth":27,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Forward message…","depth":27,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Save for later","depth":27,"on_screen":false,"role_description":"toggle button","subrole":"AXToggleButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXPopUpButton","text":"More actions","depth":27,"on_screen":false,"role_description":"pop-up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"","depth":24,"bounds":{"left":0.71666664,"top":0.88,"width":0.26527777,"height":0.04222222},"on_screen":true,"value":"","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Processing uploaded file… complete! Message ready to be sent.","depth":11,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Channel platform-inner-team","depth":11,"on_screen":false,"role_description":"text"}]...
|
1099845837087168864
|
-4237585355258855052
|
visual_change
|
hybrid
|
NULL
|
Switch workspaces… (Jiminny Inc) Has new messages
Switch workspaces… (Jiminny Inc) Has new messages
Home
Home
DMs
DMs
Activity
Activity
Files
Files
Later
Later
More…
More
Unreads
Threads
Huddles
Drafts & sent
1
Directories
jiminny-x-integration-app
platform-inner-team
ai-chapter
alerts
backend
bugs
confusion-clinic
curiosity_lab
engineering
general
happy_birthday
jiminny-bg
platform-tickets
product_launches
random
releases
sofia-office
support
thank-yous
the_people_of_jiminny
Galya Dimitrova
Nikolay Yankov
Aneliya Angelova
Stoyan Tanev
James Graham
Stefka Stoyanova
Vasil Vasilev
Stoyan Tomov
Todor Stamatov
Lukas Kovalik
you
Jira Cloud
Toast
Messages
Messages
Channel Overview
Channel Overview
More
Add and Edit Channel Tabs
Canvas
List
Folder
Jump to date
Lukas Kovalik
Yesterday at 9:44:16 AM
9:44 AM
добро утро, тази страница не трябва ли да я показваме само ако няма reports
CleanShot 2026-05-21 at [EMAIL]
Toggle file
CleanShot 2026-05-21 at [EMAIL]
4 replies
Last reply 1 day ago
View thread
Steliyan Georgiev
Yesterday at 3:05:08 PM
3:05 PM
Може ли лайк?
https://github.com/jiminny/prophet/pull/519
https://github.com/jiminny/prophet/pull/519
"This is ready to merge from a code quality standpoint."
Nikolay Yankov
Yesterday at 5:32:49 PM
5:32 PM
може ли 1 approve, че не мога да го тествам без да merge в мастер
https://github.com/jiminny/app/pull/12115
https://github.com/jiminny/app/pull/12115
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Yesterday at 5:32:57 PM
5:32
за github action-a
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Jump to date
New
Galya Dimitrova
Today at 1:24:02 PM
1:24 PM
Сторита за рефайнмънт за вторник:
https://jiminny.atlassian.net/browse/JY-20834
https://jiminny.atlassian.net/browse/JY-20834
- това го гледахме и преди но вкарах дизайн и по подробно описание и е добре пак да го минем
https://jiminny.atlassian.net/browse/JY-20836
https://jiminny.atlassian.net/browse/JY-20836
https://jiminny.atlassian.net/browse/JY-20813
https://jiminny.atlassian.net/browse/JY-20813
https://jiminny.atlassian.net/browse/JY-20814
https://jiminny.atlassian.net/browse/JY-20814
4 attachments
4 attachments
Jira Cloud Story JY-20834 MCP > Authenticate each user for easy access Story JY-20834 in Jira Cloud Preview in Slack
MCP > Authenticate each user for easy access
Story JY-20834 in Jira Cloud
Preview in Slack
Open in browser
Share Story JY-20834
View conversations
More actions
Jira Cloud Story JY-20836 MCP > Audit log Story JY-20836 in Jira Cloud Preview in Slack
MCP > Audit log
Story JY-20836 in Jira Cloud
Preview in Slack
Open in browser
Share Story JY-20836
View conversations
More actions
Jira Cloud Bug JY-20813 Twilio number is not released when Voice role is removed from user Bug JY-20813 in Jira Cloud Preview in Slack
Twilio number is not released when Voice role is removed from user
Bug JY-20813 in Jira Cloud
Preview in Slack
Open in browser
Share Bug JY-20813
View conversations
More actions
Jira Cloud Story JY-20814 Release unused Twilio numbers on Production Story JY-20814 in Jira Cloud Preview in Slack
Release unused Twilio numbers on Production
Story JY-20814 in Jira Cloud
Preview in Slack
Open in browser
Share Story JY-20814
View conversations
More actions
React with white_check_mark
React with eyes
React with raised_hands
Add reaction…
Reply in thread
Forward message…
Save for later
More actions
Processing uploaded file… complete! Message ready to be sent.
Channel platform-inner-team
SlackFileEditViewGoHistoryWindowHelpDOCKERDEV (-zsh)O $82APP (-zsh)tests/Unit/Services/Mail/0ffice/EmailApiClientTest.phptests/Unit/Services/Mail/TextRelayServiceTest.phptests/Unit/Services/RecallAI/RecallAIServiceTest.php79 files changed, 4254 insertions(+), 2020 deletions(-)create mode 100644•github/claude-reviewer/no-ticket-warning.txtcreate mode100644.github/claude-reviewer/scripts/fetch-jira-context.mjsdelete mode100644 app/Component/ES/Processor/BatchStatusInterface.phpcreate mode100644 app/Component/ES/Processor/RedisEntityExtractor.phpdelete mode100644 app/Component/ES/Processor/Traits/WorkerIdTrait.phpcreate mode100644 app/Component/ES/QueuePriorityEnum.phpcreate mode100644 front-end/src/components/DealInsights/Modals/__tests__/AskAnythingModacreate mode 100644 front-end/src/components/shared/AskAnything/__tests__/__snapshots__/Asdelete mode100644 front-end/src/components/shared/AskAnything/__tests_/_snapshots_./Ascreate mode 100644 tests/Unit/Component/ES/Processor/RedisEntityExtractorTest.phpdelete mode 100644 tests/Unit/Component/ES/Processor/Traits/WorkerIdTraitTest.phpcreate mode100644 tests/Unit/Component/ES/QueuePriorityEnumTest.phpcreate mode 100644 tests/Unit/Notifications/Activities/SmsReceivedTest.phpcreate mode100644 tests/Unit/Services/Mail/TextRelayServiceTest.phplukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ co -b JY-20963-fix-imporSwitched to a new branch 'JY-20963-fix-import-on-deleted-entity'lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/Jiminny/app (JY-20963-fix-import-on-deleted-ent|docker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix--config=.php-cs-fixer.dist.phWhat's next:Try Docker Debug forseamless, persistent debugging tools in any container or image →Learn moreat [URL_WITH_CREDENTIALS] ~/jiminny/app (JY-20963-fix-import-on-deleted-ent|docker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.phPHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminskiandcontributors.PHP runtime: 8.3.30Running analysis on 7 cores with 10 files per process.Parallel runner is an experimental feature and may be unstable, use it at your own risk.Loaded config default from".php-cs-fixer.dist.php".5691/5691 [8100%Fixed 0 of 5691 files in 96.077 seconds, 67.00 MB memory usedWhat's next:Try Docker Debug for seamless, persistent debugging tools in anycontainer orimageLearn moreat https://docs.docker.com/go/debug-cli/lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20963-fix-import-on-deleted-eAPP (...ED→HomeDMsActivityFilesLater...More+Jiminny ...w Starrea& jiminny-x-integrati...8platform-inner-teamChannels# ai-chapter# alerts# backend# bugs# confusion-clinic# curiosity_lab# engineering# general# happy_birthday# jiminny-bg# platform-tickets# product_launches# random# releases# sofia-office# support# thank-yous# the_people_of_jimi...• Direct messages. Galya Dimitrova "o Nikolay YankovR. Aneliya Angelova. Stoyan TanevIomac Grshamlahl100% C8•Fri 22 May 13:27:00Describe what you are looking for* & platform-inner-team& 106 0• MessagesP Channel OverviewMore ~+https://github.cis ready to mYesterdaypphet/pull/519 "ThisNikolay Yan.може ли 1 approve, че не мога да го тествам безда mergе в мастерhttps://github.com/jiminny/app/pull/12115за github action-aToday ~NewGalya Dimitrova *Y 1:24 PMСторита за рефайнмьнт за вторник:• https://jiminny.atlassian.net/browse/JY-20834- това го гледахме и преди но вкарах дизайни по подробно описание и е добре пак да гоминем• https://jiminny.atlassian.net/browse/JY-20836• https://jiminny.atlassian.net/browse/JY-20813• https://jiminny.atlassian.net/browse/JY-208144 attachmentsMCP › Authenticate each user for eas...Story JY-20834 in Jira CloudMCP > Audit logStory JY-20836 in Jira CloudMruhhe MuMhArHAtAnAoA MihAnMessage & platform-inner-team+Aa...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
70650
|
2536
|
16
|
2026-05-22T10:26:57.778998+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779445617778_m1.jpg...
|
Slack
|
Toast (DM) - Jiminny Inc - 2 new items - Slack
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Switch workspaces… (Jiminny Inc) Has new messages
Switch workspaces… (Jiminny Inc) Has new messages
Home
Home
DMs
DMs
Activity...
|
[{"role":"AXPopUpButton","text [{"role":"AXPopUpButton","text":"Switch workspaces… (Jiminny Inc) Has new messages","depth":14,"bounds":{"left":0.51180553,"top":0.08111111,"width":0.025,"height":0.04},"on_screen":true,"role_description":"pop-up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXRadioButton","text":"Home","depth":14,"bounds":{"left":0.50625,"top":0.14,"width":0.036111113,"height":0.075555556},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":true,"is_expanded":false},{"role":"AXStaticText","text":"Home","depth":16,"bounds":{"left":0.5138889,"top":0.19222222,"width":0.020833334,"height":0.015555556},"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"DMs","depth":14,"bounds":{"left":0.50625,"top":0.21555555,"width":0.036111113,"height":0.075555556},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"DMs","depth":16,"bounds":{"left":0.5159722,"top":0.26777777,"width":0.016666668,"height":0.015555556},"on_screen":true,"role_description":"text"},{"role":"AXRadioButton","text":"Activity","depth":14,"bounds":{"left":0.50625,"top":0.2911111,"width":0.036111113,"height":0.075555556},"on_screen":true,"role_description":"tab","subrole":"AXTabButton","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
-254204207111190222
|
-8120670020800660841
|
visual_change
|
hybrid
|
NULL
|
Switch workspaces… (Jiminny Inc) Has new messages
Switch workspaces… (Jiminny Inc) Has new messages
Home
Home
DMs
DMs
Activity
SlackFileEditViewGoHistoryWindowHelpDOCKERDEV (-zsh)О 882APP (-zsh)tests/Unit/Services/Mail/0ffice/EmailApiClientTest.phptests/Unit/Services/Mail/TextRelayServiceTest.phptests/Unit/Services/RecallAI/RecallAIServiceTest.php79 files changed, 4254 insertions(+), 2020 deletions(-)create mode 100644•github/claude-reviewer/no-ticket-warning.txtcreate mode100644.github/claude-reviewer/scripts/fetch-jira-context.mjsdelete mode100644 app/Component/ES/Processor/BatchStatusInterface.phpcreate mode100644 app/Component/ES/Processor/RedisEntityExtractor.phpdelete mode100644 app/Component/ES/Processor/Traits/WorkerIdTrait.phpcreate mode100644 app/Component/ES/QueuePriorityEnum.phpcreate mode100644 front-end/src/components/DealInsights/Modals/__tests__/AskAnythingModacreate mode 100644 front-end/src/components/shared/AskAnything/__tests__/__snapshots__/Asdelete mode100644 front-end/src/components/shared/AskAnything/__tests__/_snapshots_./Ascreate mode 100644 tests/Unit/Component/ES/Processor/RedisEntityExtractorTest.phpdelete mode 100644 tests/Unit/Component/ES/Processor/Traits/WorkerIdTraitTest.phpcreate mode100644 tests/Unit/Component/ES/QueuePriorityEnumTest.phpcreate mode 100644 tests/Unit/Notifications/Activities/SmsReceivedTest.phpcreate mode100644 tests/Unit/Services/Mail/TextRelayServiceTest.phplukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ co -b JY-20963-fix-imporSwitched to a new branch 'JY-20963-fix-import-on-deleted-entity'lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/Jiminny/app (JY-20963-fix-import-on-deleted-ent|docker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix--config=.php-cs-fixer.dist.phWhat's next:Try Docker Debug forseamless, persistent debugging tools in any container or image →Learn moreat [URL_WITH_CREDENTIALS] ~/jiminny/app (JY-20963-fix-import-on-deleted-ent|docker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.phPHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminskiandcontributors.PHP runtime: 8.3.30Running analysis on 7 cores with 10 files per process.Parallel runner is an experimental feature and may be unstable, use it at your own risk.Loaded config default from".php-cs-fixer.dist.php".5691/5691 [8100%Fixed 0 of 5691 files in 96.077 seconds, 67.00 MB memory usedWhat's next:Try Docker Debug for seamless, persistent debugging tools in anycontainer orimage →Learn moreat [URL_WITH_CREDENTIALS] review requested by @VasilVasilev#12120 Jy 20842 add deleteevents for es by @Vasil Vasilev9 commits • 14 files changedJIRA: JY-20842Changes:• Introduce events and eventlisteners that allow sending"document deleted" events for ESconsumption - both Hard and Softdeleted documents will beremoved.• Loa...Show morejiminny/appAdded by Toast for GitHubMessage Toast+Аа..•...
|
70648
|
NULL
|
NULL
|
NULL
|
|
70649
|
2537
|
19
|
2026-05-22T10:26:55.973408+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779445615973_m2.jpg...
|
Slack
|
* Toast (DM) - Jiminny Inc - 2 new items - Slack
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
PhostormProiect vVIewINavicarecoderravsco.s°9 JY-2 PhostormProiect vVIewINavicarecoderravsco.s°9 JY-20963-fix-© ServiceTest.php XT DeleteObjectsTrait.php© DeleteOpportunityJob.php= custom.log= laravel.log4 SF jiminny@localhost]• m Zoom• MZ00mBotcoomrnoneC ActivitvCrmfieldsResolverlest.ong© ActivityProviderClientTest.php©) ActivityProviderRegistryTest.php©) ActivityProviderServiceTest.php© CallDenormalizerRegistryTest.phpg crmownerkesolverlest.onpc) Meeuingbotserviceonmeeuingstaneoc MeeuingbotserviceonsharingData lesc) MeeungbotservicekequestkecoraingT SoftDeletes.pngyMatchactvitycrmbata.ongsiohp© CrmActivityService.php© RecordSelector.phpo Acuivily.onp44 432 168 V1 V.28 A V1rowv OU+tii crm_configurations [EU] X A console [STAGING]Commit Chanaesdeclare strict-cypes=l)namespace Tests Unit Services CrmChangelist: ChangesAuthor:use...Amend commitSian-oft commitclass Servicelest extends Testcaseo) PanicipantConsencservicelesc.onoe) Panicioanisservicelest.onoc) ResponseValidationTraitTest.onoc) TrackRecordingFileSizeServiceTest.or150 V5c) TrackRecordingSizeEntorcerTest.onoc)ValidateEmitProspectEventTraitTest.rActivitvProviders•AvatarN CalendanN ConferenceD CrmM BullhornCloseM Conner• M CrmOhiectsn Helpers• D HubspotD IntegrationApp• hlictenercPipedrive• MSalesforce> C Fields> • OpportunityMatcher199 % >225 vs254V403 %435 %478 %Local changesConcole ylLog Xv Chandes A filles=env.local annC.liminnvDebuaCommand.nhn ann/Console/Commandsphp logging.php config© Service.php app/Services/Crm/Salesforce• Unversioned Siles 9 filedv V MServices/Crm/Salesforce 1 filev © Service.php> _ U config 1 file= env.local1 modifieduse TestPrivatelethod:Commit checksCommit MessageJY-209631Update copvriahioublic function testfetchAndAs.Poformat codeoublic function testFetchAndAs.Rearrange codeOouimize impor'spublic function testFetchAndAspublic function testFetchAndAs:v DiftTJ<→o laee/aaoDo not ignore4 omerencesCurrent versionpublic function testFetchAndAs:$this->handle0bjectDeletion(Slead, $crmDatpUbLic tunccion cesthacchexacyehie-shandteObjectDeletion(sLead, ScrmDatdypublic function testMatchlomas.netunn Slead.19971087 Mif (Slead->trashed0) {neturn null.1090public tunction testbulldlaskspublic tunction testMapermubnereturn $lead;$this->importOpportunityCrmFieldData($1433public tunction testbetinstallnuolic tunction testsyncprorilCancecommitUl lonnortunitv assianment rule+ → alDo not ianore vHiahliaht wordswXô ?1aee7aad app/private function importContact(ScrmData): ContactContante mav not havo accounteif (isset(ScrmDatal'Accounttd!1)) d/** @var Contact Scontact */Scontact = Sthis->confiq->contacts(->update0rCreate(['crm_provider id' => ScrmData['Id']]. $data):Sthis->handle0biectDeletion(Scontact, ScrmData):4 HS_local [jiminny@localhost]& console [PROD]* console (EUlCascadeInvestigate Foreign KeCSVL8Jira Ticket:100% 52• Fri 22 May 13:26:55+O •Give me the lira ticket and the descriptionJY-XXXXX: Fix foreign key violation when matching activities to deleted Salesforce opportunitiesozyasbeyad/When an opportunity is deleted in Salesforce and the local deletion job runs, activities that belonged to that opportunity areematched via the RematchActivity0nCrmObjectDetach listener. During this rematch, the system attempts to re-link thealesforce..ndleObjectDeletion (because IsDeleted = truel)The deleted opportunity's ID is then used in Activity: :updateActivityCrmData to set activity.opportunity_id,causina a foreian key violation because the opportunity no longer exists in the databaseThe FixAdded checks in importLead. importAccount. importOpportunity. and importContact methods to return null if theentty was deleted oy hand leudi ectbeletionuphoschas→nano levo ectvelecion sopportunaty, scruvata"if (Sopportunity->trashed)) {return null:return Sopportunity:Ask anything (&OL)':"5m" "oppA difterencescurrent versionprivate function importContact(ScrmData): ?ContactSaccount = null;Contante mav not havo accounteif (isset(ScrmData['AccountId'])) {/** @var Contact Scontact *Ccontant = Cthic-sconfia-Scontactc()_Sundatoßnfnonto/ftenm_nnowidon idt = Cenmhatalitdill Cdata)Sthie, hondlonhdontholotionfchontont Canmhotol.Po 4 space....
|
NULL
|
-3537516888528247587
|
NULL
|
click
|
ocr
|
NULL
|
PhostormProiect vVIewINavicarecoderravsco.s°9 JY-2 PhostormProiect vVIewINavicarecoderravsco.s°9 JY-20963-fix-© ServiceTest.php XT DeleteObjectsTrait.php© DeleteOpportunityJob.php= custom.log= laravel.log4 SF jiminny@localhost]• m Zoom• MZ00mBotcoomrnoneC ActivitvCrmfieldsResolverlest.ong© ActivityProviderClientTest.php©) ActivityProviderRegistryTest.php©) ActivityProviderServiceTest.php© CallDenormalizerRegistryTest.phpg crmownerkesolverlest.onpc) Meeuingbotserviceonmeeuingstaneoc MeeuingbotserviceonsharingData lesc) MeeungbotservicekequestkecoraingT SoftDeletes.pngyMatchactvitycrmbata.ongsiohp© CrmActivityService.php© RecordSelector.phpo Acuivily.onp44 432 168 V1 V.28 A V1rowv OU+tii crm_configurations [EU] X A console [STAGING]Commit Chanaesdeclare strict-cypes=l)namespace Tests Unit Services CrmChangelist: ChangesAuthor:use...Amend commitSian-oft commitclass Servicelest extends Testcaseo) PanicipantConsencservicelesc.onoe) Panicioanisservicelest.onoc) ResponseValidationTraitTest.onoc) TrackRecordingFileSizeServiceTest.or150 V5c) TrackRecordingSizeEntorcerTest.onoc)ValidateEmitProspectEventTraitTest.rActivitvProviders•AvatarN CalendanN ConferenceD CrmM BullhornCloseM Conner• M CrmOhiectsn Helpers• D HubspotD IntegrationApp• hlictenercPipedrive• MSalesforce> C Fields> • OpportunityMatcher199 % >225 vs254V403 %435 %478 %Local changesConcole ylLog Xv Chandes A filles=env.local annC.liminnvDebuaCommand.nhn ann/Console/Commandsphp logging.php config© Service.php app/Services/Crm/Salesforce• Unversioned Siles 9 filedv V MServices/Crm/Salesforce 1 filev © Service.php> _ U config 1 file= env.local1 modifieduse TestPrivatelethod:Commit checksCommit MessageJY-209631Update copvriahioublic function testfetchAndAs.Poformat codeoublic function testFetchAndAs.Rearrange codeOouimize impor'spublic function testFetchAndAspublic function testFetchAndAs:v DiftTJ<→o laee/aaoDo not ignore4 omerencesCurrent versionpublic function testFetchAndAs:$this->handle0bjectDeletion(Slead, $crmDatpUbLic tunccion cesthacchexacyehie-shandteObjectDeletion(sLead, ScrmDatdypublic function testMatchlomas.netunn Slead.19971087 Mif (Slead->trashed0) {neturn null.1090public tunction testbulldlaskspublic tunction testMapermubnereturn $lead;$this->importOpportunityCrmFieldData($1433public tunction testbetinstallnuolic tunction testsyncprorilCancecommitUl lonnortunitv assianment rule+ → alDo not ianore vHiahliaht wordswXô ?1aee7aad app/private function importContact(ScrmData): ContactContante mav not havo accounteif (isset(ScrmDatal'Accounttd!1)) d/** @var Contact Scontact */Scontact = Sthis->confiq->contacts(->update0rCreate(['crm_provider id' => ScrmData['Id']]. $data):Sthis->handle0biectDeletion(Scontact, ScrmData):4 HS_local [jiminny@localhost]& console [PROD]* console (EUlCascadeInvestigate Foreign KeCSVL8Jira Ticket:100% 52• Fri 22 May 13:26:55+O •Give me the lira ticket and the descriptionJY-XXXXX: Fix foreign key violation when matching activities to deleted Salesforce opportunitiesozyasbeyad/When an opportunity is deleted in Salesforce and the local deletion job runs, activities that belonged to that opportunity areematched via the RematchActivity0nCrmObjectDetach listener. During this rematch, the system attempts to re-link thealesforce..ndleObjectDeletion (because IsDeleted = truel)The deleted opportunity's ID is then used in Activity: :updateActivityCrmData to set activity.opportunity_id,causina a foreian key violation because the opportunity no longer exists in the databaseThe FixAdded checks in importLead. importAccount. importOpportunity. and importContact methods to return null if theentty was deleted oy hand leudi ectbeletionuphoschas→nano levo ectvelecion sopportunaty, scruvata"if (Sopportunity->trashed)) {return null:return Sopportunity:Ask anything (&OL)':"5m" "oppA difterencescurrent versionprivate function importContact(ScrmData): ?ContactSaccount = null;Contante mav not havo accounteif (isset(ScrmData['AccountId'])) {/** @var Contact Scontact *Ccontant = Cthic-sconfia-Scontactc()_Sundatoßnfnonto/ftenm_nnowidon idt = Cenmhatalitdill Cdata)Sthie, hondlonhdontholotionfchontont Canmhotol.Po 4 space....
|
70647
|
NULL
|
NULL
|
NULL
|
|
70648
|
2536
|
15
|
2026-05-22T10:26:55.797533+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779445615797_m1.jpg...
|
Slack
|
* Toast (DM) - Jiminny Inc - 2 new items - Slack
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
SlackFile EditViewGoHistoryWindowHelpAPP (IDOCKERD SlackFile EditViewGoHistoryWindowHelpAPP (IDOCKERDEV (-zsh)O $82APP (-zsh)tests/Unit/Services/Mail/0ffice/EmailApiClientTest.phptests/Unit/Services/Mail/TextRelayServiceTest.phptests/Unit/Services/RecallAI/RecallAIServiceTest.php79 files changed, 4254 insertions(+), 2020 deletions(-)create mode 100644•github/claude-reviewer/no-ticket-warning.txtcreate mode100644.github/claude-reviewer/scripts/fetch-jira-context.mjsdelete mode100644 app/Component/ES/Processor/BatchStatusInterface.phpcreate mode100644 app/Component/ES/Processor/RedisEntityExtractor.phpdelete mode100644 app/Component/ES/Processor/Traits/WorkerIdTrait.phpcreate mode100644 app/Component/ES/QueuePriorityEnum.phpcreate mode100644 front-end/src/components/DealInsights/Modals/__tests__/AskAnythingModacreatemode 100644 front-end/src/components/shared/AskAnything/__tests__/__snapshots__/Asdeletemode100644 front-end/src/components/shared/AskAnything/__tests__/__snapshots_/Ascreatemode100644 tests/Unit/Component/ES/Processor/RedisEntityExtractorTest.phpdelete mode100644 tests/Unit/Component/ES/Processor/Traits/WorkerIdTraitTest.phpcreate mode100644 tests/Unit/Component/ES/QueuePriorityEnumTest.phpcreate mode100644 tests/Unit/Notifications/Activities/SmsReceivedTest.phpcreate mode100644 tests/Unit/Services/Mail/TextRelayServiceTest.phplukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ co -b JY-20963-fix-imporSwitched to a new branch 'JY-20963-fix-import-on-deleted-entity'lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/Jiminny/app (JY-20963-fix-import-on-deleted-ent|docker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix--config=.php-cs-fixer.dist.phWhat's next:Try Docker Debug forseamless, persistent debugging tools in any container or image →Learn moreat [URL_WITH_CREDENTIALS] ~/jiminny/app (JY-20963-fix-import-on-deleted-ent)docker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.phPHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminski15 umconafi amp cont-fbteo-di5t. phPHP runtime: 8.3.30Running analysis on 7 cores with 10 files per process.Parallel runner is an experimental feature and may be unstable, use it at your own risk.Loaded config default from".php-cs-fixer.dist.php".5691/5691 [8100%Fixed 0 of 5691 files in 96.077 seconds, 67.00 MB memory usedWhat's next:Try Docker Debug for seamless, persistent debugging tools in anycontainer orimage →Learn moreat [URL_WITH_CREDENTIALS] review requested by @VasilVasilev#12120 Jy 20842 add deleteevents for es by @Vasil Vasilev9 commits • 14 files changedJIRA: JY-20842Changes:• Introduce events and eventlisteners that allow sending"document deleted" events for ESconsumption - both Hard and Softdeleted documents will beremoved.• Loa...Show morejiminny/appAdded by Toast for GitHubMessage Toast+Aa..•...
|
NULL
|
-7501182405663302989
|
NULL
|
click
|
ocr
|
NULL
|
SlackFile EditViewGoHistoryWindowHelpAPP (IDOCKERD SlackFile EditViewGoHistoryWindowHelpAPP (IDOCKERDEV (-zsh)O $82APP (-zsh)tests/Unit/Services/Mail/0ffice/EmailApiClientTest.phptests/Unit/Services/Mail/TextRelayServiceTest.phptests/Unit/Services/RecallAI/RecallAIServiceTest.php79 files changed, 4254 insertions(+), 2020 deletions(-)create mode 100644•github/claude-reviewer/no-ticket-warning.txtcreate mode100644.github/claude-reviewer/scripts/fetch-jira-context.mjsdelete mode100644 app/Component/ES/Processor/BatchStatusInterface.phpcreate mode100644 app/Component/ES/Processor/RedisEntityExtractor.phpdelete mode100644 app/Component/ES/Processor/Traits/WorkerIdTrait.phpcreate mode100644 app/Component/ES/QueuePriorityEnum.phpcreate mode100644 front-end/src/components/DealInsights/Modals/__tests__/AskAnythingModacreatemode 100644 front-end/src/components/shared/AskAnything/__tests__/__snapshots__/Asdeletemode100644 front-end/src/components/shared/AskAnything/__tests__/__snapshots_/Ascreatemode100644 tests/Unit/Component/ES/Processor/RedisEntityExtractorTest.phpdelete mode100644 tests/Unit/Component/ES/Processor/Traits/WorkerIdTraitTest.phpcreate mode100644 tests/Unit/Component/ES/QueuePriorityEnumTest.phpcreate mode100644 tests/Unit/Notifications/Activities/SmsReceivedTest.phpcreate mode100644 tests/Unit/Services/Mail/TextRelayServiceTest.phplukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ co -b JY-20963-fix-imporSwitched to a new branch 'JY-20963-fix-import-on-deleted-entity'lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/Jiminny/app (JY-20963-fix-import-on-deleted-ent|docker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix--config=.php-cs-fixer.dist.phWhat's next:Try Docker Debug forseamless, persistent debugging tools in any container or image →Learn moreat [URL_WITH_CREDENTIALS] ~/jiminny/app (JY-20963-fix-import-on-deleted-ent)docker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.phPHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminski15 umconafi amp cont-fbteo-di5t. phPHP runtime: 8.3.30Running analysis on 7 cores with 10 files per process.Parallel runner is an experimental feature and may be unstable, use it at your own risk.Loaded config default from".php-cs-fixer.dist.php".5691/5691 [8100%Fixed 0 of 5691 files in 96.077 seconds, 67.00 MB memory usedWhat's next:Try Docker Debug for seamless, persistent debugging tools in anycontainer orimage →Learn moreat [URL_WITH_CREDENTIALS] review requested by @VasilVasilev#12120 Jy 20842 add deleteevents for es by @Vasil Vasilev9 commits • 14 files changedJIRA: JY-20842Changes:• Introduce events and eventlisteners that allow sending"document deleted" events for ESconsumption - both Hard and Softdeleted documents will beremoved.• Loa...Show morejiminny/appAdded by Toast for GitHubMessage Toast+Aa..•...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
70646
|
2536
|
14
|
2026-05-22T10:26:45.609010+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779445605609_m1.jpg...
|
PhpStorm
|
Commit Changes
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Diff
Changelist:
Changelist:
Changes
Show Diff
Rol Diff
Changelist:
Changelist:
Changes
Show Diff
Rollback...
Refresh
Group By
Expand All
Collapse All
1 modified
JY-2096
Commit Message
Commit Message History
Git
Author:
Author:
Amend commit
Sign-off commit
Create extra commit with file movements
Commit Checks
Update copyright
Reformat code
Rearrange code
Optimize imports
Cleanup
Choose profile
Check malicious dependencies
Run external formatter
Configure
Perform SonarQube for IDE analysis
Find unused view files
Run Git hooks
Advanced Commit Checks
Analyze code
Choose profile
Check TODO
Configure
After Commit
Upload files to:
Upload files to:
<None>
Always use selected server or group of servers
Previous Difference
Next Difference
Jump to Source
Compare Previous File
Compare Next File
Go to Changed File…
Side-by-side viewer
Do not ignore
Highlight words
Collapse Unchanged Fragments
Synchronize Scrolling
Disable Editing
Settings
Help
4 differences
1aee7aad
<?php
namespace Jiminny\Services\Crm\Salesforce;
use Carbon\Carbon;
use Exception;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Events\Dispatcher;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Jiminny\Component\Country\CountriesMap;
use Jiminny\Contracts\Acl\PermissionEnum;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Contracts\Services\Crm\FetchRelatedActivityInterface;
use Jiminny\Contracts\Services\Crm\ImportsBusinessProcessesInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\SalesforceBatchSyncInterface;
use Jiminny\Contracts\Services\Crm\Provider\SalesforceInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\RemoteNoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SearchTaskInterface;
use Jiminny\Contracts\Services\Crm\SendSummaryToCrmInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SupportsObjectTypeParseInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmProfileRecordTypesInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Enums\CrmObject;
use Jiminny\Events\Activities\Crm\LeadConverted;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpBadRequestException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Exceptions\NoResultsException;
use Jiminny\Exceptions\ServiceUnavailableException;
use Jiminny\Jobs\Crm\NoteObject;
use Jiminny\Jobs\Crm\MatchActivitiesToNewOpportunity;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Calendar\CalendarEvent;
use Jiminny\Models\Contact;
use Jiminny\Models\Contracts\ActivityContract;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Models\Crm\ContactRole;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Crm\RecordType;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Playbook;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\TeamSettings;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\ContactRoleRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\Crm\RecordTypeFieldValuesRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Helpers\ArrayIterator;
use Jiminny\Services\Crm\MatchDomainByEmailInterface;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Services\Crm\ResolveCompanyNameByEmailTrait;
use Jiminny\Services\Crm\Salesforce\Fields\FieldHelper;
use Jiminny\Services\Crm\Salesforce\Fields\FieldTypeConverter;
use Jiminny\Services\Crm\Salesforce\Fields\ValueNormalizer;
use Jiminny\Services\Crm\Salesforce\ServiceTraits\FollowupActivityTrait;
use Jiminny\Services\Crm\Salesforce\ServiceTraits\LogActivityTrait;
use Jiminny\Services\Crm\Salesforce\ServiceTraits\RecordManipulationsTrait;
use Jiminny\Services\Crm\Salesforce\ServiceTraits\SyncFieldsTrait;
use Jiminny\Utils\CurrencyFormatter;
use Jiminny\Utils\StringUtil;
use Ramsey\Uuid\Uuid;
use Sentry\Laravel\Facade as Sentry;
class Service extends BaseService implements
SalesforceInterface,
SalesforceBatchSyncInterface,
SyncCrmEntitiesInterface,
SyncCrmProfileRecordTypesInterface,
ImportsBusinessProcessesInterface,
RemoteEntityManipulationInterface,
FetchRelatedActivityInterface,
SendSummaryToCrmInterface,
MatchDomainByEmailInterface,
SearchTaskInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
SupportsObjectTypeParseInterface,
RemoteNoteEntityManipulationInterface,
VerifyTaskExistsInterface
{
use ResolveCompanyNameByEmailTrait;
use SyncFieldsTrait;
use DeleteObjectsTrait;
use RecordManipulationsTrait;
use ServiceTraits\BatchSyncTrait;
use FollowupActivityTrait;
use LogActivityTrait;
/**
* Note Body Limit for the Old Note-Taking Tool
*
* @var int
*/
private const int CLASSIC_NOTE_MAX_LENGTH = 32000;
/**
* Note Content Limit for the New Notes
*
* @var int
*/
private const int ENHANCED_NOTE_MAX_LENGTH = 50000000;
private const string INSTALLED_PACKAGE_ID = '033Tw0000007bKbIAI';
private const int CACHE_TTL = 600;
private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day - 86400
/**
* @var Client
*/
protected $client;
protected PayloadBuilder $payloadBuilder;
protected QueryHandler $queryHandler;
private OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
public function __construct(
Client $client,
PayloadBuilder $payloadBuilder,
protected Dispatcher $eventDispatcher,
private readonly CountriesMap $countriesMap,
private readonly ProspectPhotoPathService $prospectPhotoPathService,
) {
parent::__construct();
$this->client = $client;
$this->payloadBuilder = $payloadBuilder;
$this->queryHandler = app(QueryHandler::class, [
'client' => $this->client,
'logger' => $this->logger,
]);
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
}
public function getDisplayName(): string
{
return 'Salesforce';
}
public function getJobDelay(): int
{
return 1;
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
return $user->getSocialAccount(SocialAccount::PROVIDER_SALESFORCE);
}
public function verifyTaskExists(Activity $activity): bool
{
$crmProviderId = $activity->getCrmProviderId();
$cacheKey = "crm_task_exists:{$this->config->getId()}:$crmProviderId";
return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($activity, $crmProviderId) {
$playbook = $this->getPlaybookFromActivity($activity);
if ($playbook === null) {
$this->logger->warning('[Salesforce] Cannot verify task - no playbook found', [
'activity' => $activity->getId(),
'crm_provider_id' => $crmProviderId,
]);
return false;
}
$objectType = $playbook->getActivityType() === Playbook::ACTIVITY_TYPE_EVENT ? 'Event' : 'Task';
try {
$record = $this->getRecord($objectType, $crmProviderId, ['Id', 'IsDeleted']);
return ! empty($record) && ($record['IsDeleted'] ?? false) === false;
} catch (HttpNotFoundException|HttpBadRequestException) {
$this->logger->info('[Salesforce] Activity record not found during verification', [
'activity' => $activity->getId(),
'object_type' => $objectType,
'crm_provider_id' => $crmProviderId,
'config_id' => $this->config->getId(),
]);
return false;
}
});
}
public function query(string $queryToRun, array $parameters = []): QueryIterator
{
// Due to poorly designed external calls, this method cannot be entirely removed
return $this->queryHandler->query($queryToRun, $parameters);
}
/*=========== Organization Information ===============*/
/**
* Get a list of all the API Versions for the instance.
*
* @throws CrmException
*
* @return mixed
*
*/
public function getApiVersions()
{
$url = $this->config->crm_base_url . '/services/data';
$response = $this->client->get($url);
return json_decode($response->getBody(), true);
}
/**
* Gets the valid recordTypes for a given Salesforce Object via the describe API.
*/
private function getRecordTypes(string $crmObject): array
{
$url = $this->client->getObjectsUrl() . $crmObject . '/describe';
$response = $this->client->get($url);
$jsonResponse = json_decode($response->getBody(), true);
$fields = [];
foreach ($jsonResponse['recordTypeInfos'] as $row) {
$fields[] = ['recordTypeId' => $row['recordTypeId'], 'default' => $row['defaultRecordTypeMapping']];
}
return $fields;
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return ValueNormalizer::normalize($fieldType, $fieldValue, $internal);
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
$defaultFields = ($activityType === Playbook::ACTIVITY_TYPE_TASK)
? FieldDefinitions::defaultTaskFields()
: FieldDefinitions::defaultEventFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
// Setup the activity field as the default Type.
/** @var Field $activityField */
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'Type',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK, Playbook::ACTIVITY_TYPE_EVENT];
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldFilter = ($activityType === Playbook::ACTIVITY_TYPE_TASK)
? FieldDefinitions::taskFollowupFieldsFilter()
: FieldDefinitions::eventFollowupFieldsFilter();
foreach ($fieldFilter as $eachFilter) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $eachFilter);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
/**
* This one is now called only when ImportActivityTypes is triggered or SyncFieldMetadata executed manually
* Regular sync now uses SharedSyncFieldsTrait -> syncSingleObjectType
* Needs to be replaced later on
*/
public function syncField(Field $field): void
{
try {
if (FieldHelper::isCustomField($field)) {
$query = '
SELECT
Id, Metadata, TableEnumOrId
FROM
CustomField
WHERE
DeveloperName = :fieldName
AND
TableEnumOrId = :fieldType
AND
NamespacePrefix = :namespacePrefix';
// We need to constrain the field lookup to the object, in case it's used in multiple places.
$objectType = \in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true)
? 'activity'
: $field->object_type;
$sfFields = $this->queryHandler->metadata($query, [
'fieldName' => substr($field->crm_provider_id, 0, -\strlen('__c')),
'fieldType' => ucfirst($objectType),
// This is used to ensure we only consider the field within the org, not installed packages.
'namespacePrefix' => 'null',
]);
// There is always 1 result at this point.
$sfField = $sfFields->current();
// Sync field metadata.
$metadata = $sfField['Metadata'];
$field->description = mb_strimwidth($metadata['description'] ?? '', 0, 191);
$field->label = mb_strimwidth($metadata['label'] ?? '', 0, Field::LABEL_MAX_LENGTH);
$field->type = $this->convertFieldType($metadata['type'], $field->getEntityName());
$field->is_mandatory = ($metadata['required'] === true);
$field->length = $metadata['length'];
$field->default_value = mb_strimwidth(trim($metadata['defaultValue'] ?? '', '"'), 0, 191);
$field->save();
} else {
$query = '
SELECT
Id, DataType, DeveloperName, Label, Length, Description
FROM
FieldDefinition
WHERE
DurableId = :entityName';
$entityName = $field->getEntityName();
$sfFields = $this->queryHandler->metadata($query, [
'entityName' => $entityName,
]);
// There is always 1 result at this point.
$sfField = $sfFields->current();
$convertedType = $this->convertFieldType($sfField['DataType'], $entityName);
$label = mb_strimwidth($sfField['Label'], 0, Field::LABEL_MAX_LENGTH);
if ($field->isBusinessType()) {
$label = 'Opportunity Type';
}
$field->description = mb_strimwidth($sfField['Description'], 0, Field::DESCRIPTION_MAX_LENGTH);
$field->label = $label;
$field->type = $convertedType;
$field->length = $sfField['Length'];
$field->save();
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
}
private function convertFieldType(string $from, ?string $entityName = null): string
{
$converter = new FieldTypeConverter();
return $converter->convert($from, $entityName);
}
/**
* @inheritdoc
*/
public function importPicklistValues(Field $field): array
{
$values = [];
$fieldValues = [];
try {
if (FieldHelper::isCustomField($field)) {
$query = '
SELECT
Id, Metadata, TableEnumOrId
FROM
CustomField
WHERE
DeveloperName = :fieldName
AND
TableEnumOrId = :fieldType
AND
NamespacePrefix = :namespacePrefix';
// We need to constrain the field lookup to the object, in case it's used in multiple places.
$objectType = \in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true) ?
'activity' : $field->object_type;
$sfFields = $this->queryHandler->metadata($query, [
'fieldName' => substr($field->crm_provider_id, 0, -\strlen('__c')),
'fieldType' => ucfirst($objectType),
// This is used to ensure we only consider the field within the org, not installed packages.
'namespacePrefix' => 'null',
]);
// There is always 1 result at this point.
$sfField = $sfFields->current();
$valueSet = $sfField['Metadata']['valueSet'];
if ($valueSet['valueSetName'] === null) {
// Local picklist values can be obtained easily.
$picklistValues = $valueSet['valueSetDefinition']['value'];
} else {
// But for some fields, we just get the Global Value Picklist pointer so need to do more work.
$picklistValues = $this->importGlobalValuePicklistValues($valueSet['valueSetName']);
}
// Import all active values.
foreach ($picklistValues as $i => $sfFieldValue) {
// Setup default value.
if ($sfFieldValue['default']) {
$field->update(['default_value' => $sfFieldValue['valueName']]);
}
// This comes through as null if active (lol).
if ($sfFieldValue['isActive'] !== false) {
$values[] = [
'value' => $sfFieldValue['valueName'],
'label' => $sfFieldValue['valueName'],
'sequence' => $i,
'is_default' => $sfFieldValue['default'],
];
}
}
} else {
$objectFields = $this->getObjectFields($field->object_type);
$fieldId = $field->crm_provider_id;
// Only work with our field of interest.
$objectField = array_filter($objectFields, function ($item) use ($fieldId) {
return $item['name'] === $fieldId;
});
$objectField = array_shift($objectField);
if (empty($objectField['picklistValues']) === false) {
foreach ($objectField['picklistValues'] as $i => $sfFieldValue) {
// Skip inactive values.
if ($sfFieldValue['active'] === false) {
continue;
}
// Setup default value.
if ($sfFieldValue['defaultValue']) {
$field->update(['default_value' => $sfFieldValue['value']]);
}
$values[] = [
'value' => $sfFieldValue['value'],
'label' => $sfFieldValue['label'],
'sequence' => $i,
'is_default' => $sfFieldValue['defaultValue'],
];
}
}
}
$fieldsToPurge = $field->values()->get()->pluck('value')->toArray();
foreach ($values as $value) {
$value['value'] = substr($value['value'] ?? '', 0, 255);
$fieldValues[] = $field->values()->updateOrCreate([
'value' => $value['value'],
], $value);
// Remove this value from the ones we are going to purge.
if (($key = array_search($value['value'], $fieldsToPurge, true)) !== false) {
unset($fieldsToPurge[$key]);
}
}
// Delete the old values that are no longer used.
// Get IDs of the values to be deleted
$valuesToDelete = $field->values()->whereIn('value', $fieldsToPurge);
$valuesToDeleteIds = $valuesToDelete->pluck('id');
if (! $valuesToDeleteIds->isEmpty()) {
$recordTypeFieldValuesRepository = app(RecordTypeFieldValuesRepository::class);
$recordTypeFieldValuesRepository->deleteForCrmFieldValueIds($valuesToDeleteIds->toArray());
// Now safely delete from crm_field_values
$valuesToDelete->delete();
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
return $fieldValues;
}
/**
* Gets values from Global Value Picklists.
*/
private function importGlobalValuePicklistValues(string $picklistName): array
{
$query = '
SELECT
Metadata
FROM
GlobalValueSet
WHERE
DeveloperName = :picklistName
LIMIT 1';
try {
$sfValues = $this->queryHandler->metadata($query, [
'picklistName' => $picklistName,
]);
// There is always 1 result at this point.
$sfValue = $sfValues->current();
return $sfValue['Metadata']['customValue'];
} catch (NoResultsException $noResultsException) {
// Nothing returned.
return [];
}
}
/**
* @inheritdoc
*/
public function syncProfileRecordTypes(): void
{
$objectTypes = [
'lead',
'account',
'contact',
'opportunity',
'task',
'event',
];
foreach ($objectTypes as $objectType) {
try {
$crmRecordTypes = $this->getRecordTypes(ucfirst($objectType));
foreach ($crmRecordTypes as $crmRecordType) {
// If the record type is default and not the Master type, set this.
if ($crmRecordType['default'] && $crmRecordType['recordTypeId'] !== '012000000000000AAA') {
$recordType = $this->config->recordTypes()
->where('crm_provider_id', $crmRecordType['recordTypeId'])
->first();
if ($recordType) {
$this->profile->{$objectType . '_record_type_id'} = $recordType->id;
}
}
}
} catch (HttpNotFoundException $exception) {
Log::error('No access to ' . $objectType . ' object, skipping...');
// XXX: should we log this fact somewhere?
continue;
}
}
if ($this->profile->isDirty()) {
$this->profile->save();
}
}
/**
* Gets business processes.
*/
public function importBusinessProcesses(): void
{
$query = '
SELECT
Id, IsActive, Name, TableEnumOrId
FROM
BusinessProcess
WHERE
TableEnumOrId IN (\'Lead\',\'Opportunity\')';
try {
$sfProcesses = $this->queryHandler->query($query);
// Upsert all processes for the team.
foreach ($sfProcesses as $sfProcess) {
/** @var BusinessProcess $businessProcess */
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => $sfProcess['Id'],
], [
'team_id' => $this->team->id,
'name' => $sfProcess['Name'],
'type' => $sfProcess['TableEnumOrId'] === 'Lead' ? 'lead' : 'opportunity',
'is_selectable' => $sfProcess['IsActive'],
]);
$this->importBusinessProcessStages($businessProcess);
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
}
/**
* Gets business process stages.
*/
private function importBusinessProcessStages(BusinessProcess $businessProcess): void
{
$query = '
SELECT
Metadata
FROM
BusinessProcess
WHERE
Id = :processId';
try {
$stages = [];
$sfProcessStages = $this->queryHandler->metadata($query, [
'processId' => $businessProcess->crm_provider_id,
]);
// There is always 1 result at this point.
$sfProcessStage = $sfProcessStages->current();
// Upsert all processes for the team.
foreach ($sfProcessStage['Metadata']['values'] as $sfProcessStage) {
$sanitizedName = urldecode($sfProcessStage['valueName']); // Must decode: "%2C" becomes "," etc.
$stage = $businessProcess->crm->stages()
// This MUST match on label because this API doesn't use API Name.
->where('label', $sanitizedName)
->where('type', $businessProcess->type)
->where('is_selectable', 1)
->first();
if ($stage) {
$stages[] = $stage->id;
}
}
$businessProcess->stages()->sync($stages);
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
}
/**
* Gets record types.
*/
public function importRecordTypes(): void
{
$query = '
SELECT
Id, IsActive, Name, BusinessProcessId, SobjectType
FROM
RecordType';
try {
$sfRecordTypes = $this->queryHandler->query($query);
// Upsert all record types for the process.
foreach ($sfRecordTypes as $sfRecordType) {
$businessProcess = null;
if ($sfRecordType['BusinessProcessId']) {
$businessProcess = $this->config->businessProcesses()
->where('crm_provider_id', $sfRecordType['BusinessProcessId'])
->first();
}
/** @var RecordType $recordType */
$recordType = $this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => $sfRecordType['Id'],
], [
'team_id' => $this->team->id,
'type' => mb_strtolower($sfRecordType['SobjectType']),
'name' => $sfRecordType['Name'],
'is_selectable' => $sfRecordType['IsActive'],
'business_process_id' => $businessProcess->id ?? null,
]);
$this->importRecordTypeFieldValues($recordType);
}
} catch (NoResultsException $noResultsException) {
// Do nothing.
}
}
/**
* Import record type - field value mappings. This only works for standard fields.
*/
private function importRecordTypeFieldValues(RecordType $recordType): void
{
try {
$query = '
SELECT
Metadata
FROM
RecordType
WHERE
Id = :recordTypeId';
$sfFields = $this->queryHandler->metadata($query, [
'recordTypeId' => $recordType->crm_provider_id,
]);
// There is always 1 result at this point.
$sfField = $sfFields->current();
// Sync field metadata.
$picklists = $sfField['Metadata']['picklistValues'];
foreach ($picklists as $picklist) {
$field = $this->config->fields()->where([
'type' => Field::TYPE_PICKLIST,
'object_type' => $recordType->type,
'crm_provider_id' => $picklist['picklist'],
])->first();
if ($field) {
$fieldValues = [];
foreach ($picklist['values'] as $value) {
// Must decode: "%2C" becomes "," etc.
$fieldValue = $field->values()
->where('value', urldecode($value['valueName']))
->first();
if ($fieldValue) {
$fieldValues[] = $fieldValue->id;
}
}
$recordType->fieldValues()->sync($fieldValues);
}
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
}
/**
* @inheritdoc
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$params = [];
$missingStage = null;
if ($types === null) {
$types = [Stage::TYPE_LEAD, Stage::TYPE_OPPORTUNITY];
}
foreach ($types as $type) {
if ($type === Stage::TYPE_LEAD) {
$query = '
SELECT
Id, ApiName, MasterLabel, SortOrder
FROM
LeadStatus';
} else {
$query = '
SELECT
Id, ApiName, MasterLabel, IsActive, SortOrder, DefaultProbability
FROM
OpportunityStage';
}
if ($missingStageName) {
$escapedStageName = ValueNormalizer::replaceQueryWithStringLiterals($missingStageName);
$query .= ' WHERE ApiName = :stageName';
$params = [
'stageName' => $escapedStageName,
];
}
try {
$sfStages = $this->queryHandler->query($query, $params);
} catch (NoResultsException $exception) {
$sfStages = [];
}
$missingStage = null;
// Upsert all stages for the team.
foreach ($sfStages as $sfStage) {
$selectable = true;
if (array_key_exists('IsActive', $sfStage)) {
$selectable = $sfStage['IsActive'];
}
$this->restoreAnyTrashedEntity($this->config->stages(), $sfStage['Id']);
$stage = $this->config->stages()->updateOrCreate([
'crm_provider_id' => $sfStage['Id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($sfStage['ApiName'], 0, 50),
'label' => mb_strimwidth($sfStage['MasterLabel'], 0, 191),
'type' => $type,
'sequence' => $sfStage['SortOrder'] ?? 0,
'is_selectable' => $selectable,
'probability' => $sfStage['DefaultProbability'] ?? null,
]);
if ($missingStageName && $missingStageName === $sfStage['ApiName']) {
$missingStage = $stage;
}
}
if ($missingStageName && $missingStage === null) {
// If they requested a stage that still doesn't exist, it must be inactive so lazy create it.
$missingStage = $this->config->stages()->create([
'crm_provider_id' => Uuid::uuid4(),
'team_id' => $this->team->id,
'name' => mb_strimwidth($missingStageName, 0, 50),
'label' => mb_strimwidth($missingStageName, 0, 191),
'type' => $type,
'sequence' => 0,
'is_selectable' => 0,
]);
}
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int
{
$syncCount = 0;
$fields = $this->getAllFieldsAsArray('lead');
if (\in_array('Id', $fields, true) === false) {
return $syncCount;
}
$query = '
SELECT ' . rtrim(implode(',', $fields), ',') . '
FROM Lead
WHERE LastModifiedDate > :since
ORDER BY LastModifiedDate ASC';
try {
$sfLeads = $this->queryHandler->query($query, [
'since' => $since->format('Y-m-d\TH:i:s\Z'),
]);
foreach ($sfLeads as $sfLead) {
// Only sync if previously imported.
if ($this->hasLead($sfLead['Id'])) {
$this->importLead($sfLead);
$syncCount++;
}
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
$this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::LEAD);
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncLead(string $crmId): ?Lead
{
$fields = $this->getAllFieldsAsArray('lead');
$sfLead = $this->getRecord('Lead', $crmId, $fields);
return $this->importLead($sfLead);
}
private function importLead($crmData): ?Lead
{
/** @var ?Stage $stage */
$stage = null;
if (isset($crmData['Status'])) {
// Get the current stage.
$stage = $this->config
->stages()
->where('name', $crmData['Status'])
->where('type', Stage::TYPE_LEAD)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_LEAD], $crmData['Status']);
}
}
// If we have no way of importing this, just return null :(
if ($stage === null) {
return null;
}
$countryCode = $crmData['CountryCode'] ?? null;
// Salesforce allows custom "countries" to be created. Disregard these.
if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {
$countryCode = null;
}
// If we have no country code, try to parse it from the country name.
if ($countryCode === null && empty($crmData['Country']) !== false) {
$countryCode = $this->convertCountryNameToCode($crmData['Country']);
}
// Trim to our width and attempt to parse it.
$number = mb_strimwidth($crmData['Phone'] ?? '', 0, 25);
$parsedNumber = parsePhoneNumber($countryCode, $number);
$mobilePhone = null;
if (empty($crmData['MobilePhone']) === false) {
// Trim to our width and attempt to parse it.
$number = mb_strimwidth($crmData['MobilePhone'], 0, 25);
$mobilePhone = phone_e164($countryCode, $number);
}
$convertedDate = null;
$convertedAccount = null;
$convertedOpportunity = null;
$convertedContact = null;
if ($crmData['IsConverted'] == 'true') {
$convertedDate = $crmData['ConvertedDate'];
if (empty($crmData['ConvertedAccountId']) === false) {
$convertedAccount = $this->config
->accounts()
->where('crm_provider_id', $crmData['ConvertedAccountId'])
->first();
if ($convertedAccount === null) {
try {
$convertedAccount = $this->syncAccount($crmData['ConvertedAccountId']);
} catch (HttpNotFoundException $exception) {
// Probably the user has no permissions to access the converted data.
}
}
}
if (empty($crmData['ConvertedOpportunityId']) === false) {
$convertedOpportunity = $this->config
->opportunities()
->where('crm_provider_id', $crmData['ConvertedOpportunityId'])
->first();
if ($convertedOpportunity === null) {
try {
$convertedOpportunity = $this->syncOpportunity($crmData['ConvertedOpportunityId']);
} catch (HttpNotFoundException $exception) {
// Probably the user has no permissions to access the converted data.
}
}
}
if (empty($crmData['ConvertedContactId']) === false) {
$convertedContact = $this->team
->crm
->contacts()
->where('crm_provider_id', $crmData['ConvertedContactId'])
->first();
if ($convertedContact === null) {
try {
$convertedContact = $this->syncContact($crmData['ConvertedContactId']);
} catch (HttpNotFoundException $exception) {
// Probably the user has no permissions to access the converted data.
}
}
}
}
if (empty($crmData['Company'])) {
$company = 'Unknown';
} else {
$company = mb_strimwidth($crmData['Company'], 0, 191);
}
$domain = null;
if (empty($crmData['Website']) === false) {
$domain = mb_strimwidth($crmData['Website'], 0, 191);
$domain = StringUtil::resolveDomain($domain);
}
$createdDate = null;
if (empty($crmData['CreatedDate']) === false) {
$createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');
}
$profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);
$data = [
'team_id' => $this->team->id,
'user_id' => $profile?->user_id,
'owner_id' => $crmData['OwnerId'] ?? '',
'company' => $company,
'domain' => $domain,
'name' => $crmData['Name'] ? mb_strimwidth($crmData['Name'], 0, 191) : '',
'title' => $crmData['Title'] ? mb_strimwidth($crmData['Title'], 0, 128) : null,
'email' => $crmData['Email'] ? mb_strimwidth($crmData['Email'], 0, 80) : null,
'phone' => $parsedNumber['phone'],
'ext' => $parsedNumber['ext'] ?? null,
'mobile_phone' => $mobilePhone,
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: $crmData['Id'],
modelType: Lead::class,
fileName: $crmData['Id'],
avatarText: $crmData['Name']
),
'stage_id' => $stage->id,
'record_type_id' => null,
'converted_at' => $convertedDate,
'converted_account_id' => $convertedAccount->id ?? null,
'converted_opportunity_id' => $convertedOpportunity->id ?? null,
'converted_contact_id' => $convertedContact->id ?? null,
'country_code' => $countryCode,
'remotely_created_at' => $createdDate,
];
$this->restoreAnyTrashedEntity($this->config->leads(), $crmData['Id']);
/** @var Lead $lead */
$lead = $this->config->leads()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);
if ($lead->wasChanged('converted_at') && $lead->getConvertedAt() !== null) {
$this->eventDispatcher->dispatch(new LeadConverted($lead));
}
$this->handleObjectDeletion($lead, $crmData);
return $lead;
}
/**
* @inheritdoc
*/
public function syncAccounts(Carbon $since, ?Carbon $to = null): int
{
$syncCount = 0;
$fields = $this->getAllFieldsAsArray('account');
if (\in_array('Id', $fields, true) === false) {
return $syncCount;
}
$query = '
SELECT ' . rtrim(implode(',', $fields), ',') . '
FROM Account
WHERE LastModifiedDate > :since
ORDER BY LastModifiedDate ASC';
try {
$sfAccounts = $this->queryHandler->query($query, [
'since' => $since->format('Y-m-d\TH:i:s\Z'),
]);
foreach ($sfAccounts as $sfAccount) {
// Only sync if previously imported.
if ($this->hasAccount($sfAccount['Id'])) {
$this->importAccount($sfAccount);
$syncCount++;
}
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
$this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::ACCOUNT);
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncAccount(string $crmId): ?Account
{
$fields = $this->getAllFieldsAsArray('account');
if (! in_array('Id', $fields, true)) {
$this->logger->info('[Salesforce] Sync account cancelled. Fields are not available.', [
'crmId' => $crmId,
'userId' => $this->profile->getUserId(),
]);
return null;
}
$sfAccount = $this->getRecord('Account', $crmId, $fields);
return $this->importAccount($sfAccount);
}
private function importAccount($crmData): Account
{
$countryCode = $crmData['BillingCountryCode'] ?? $crmData['ShippingCountryCode'] ?? null;
// Salesforce allows custom "countries" to be created. Disregard these.
if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {
$countryCode = null;
}
// If we have no country code, try to parse it from the country names.
if ($countryCode === null && empty($crmData['BillingCountry']) === false) {
$countryCode = $this->convertCountryNameToCode($crmData['BillingCountry']);
}
if ($countryCode === null && empty($crmData['ShippingCountry']) === false) {
$countryCode = $this->convertCountryNameToCode($crmData['ShippingCountry']);
}
if (empty($crmData['Phone']) === false) {
// Trim to our width and attempt to parse it.
$number = mb_strimwidth($crmData['Phone'], 0, 25);
$parsedNumber = parsePhoneNumber($countryCode, $number);
} else {
$parsedNumber = [];
}
$industry = null;
if (empty($crmData['Industry']) === false) {
$industry = mb_strimwidth($crmData['Industry'], 0, 40);
}
$domain = null;
if (empty($crmData['Website']) === false) {
$domain = mb_strimwidth($crmData['Website'], 0, 191);
$domain = StringUtil::resolveDomain($domain);
}
$createdDate = null;
if (empty($crmData['CreatedDate']) === false) {
$createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');
}
$profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);
$data = [
'team_id' => $this->team->id,
'user_id' => $profile?->user_id,
'owner_id' => $crmData['OwnerId'],
'name' => mb_strimwidth($crmData['Name'], 0, 191),
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: $crmData['Id'],
modelType: Account::class,
fileName: $crmData['Id'],
avatarText: $crmData['Name']
),
'industry' => $industry,
'domain' => $domain,
'phone' => $parsedNumber['phone'] ?? null,
'ext' => $parsedNumber['ext'] ?? null,
'country_code' => $countryCode,
'remotely_created_at' => $createdDate,
];
$this->restoreAnyTrashedEntity($this->config->accounts(), $crmData['Id']);
/** @var Account $account */
$account = $this->config->accounts()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);
$this->handleObjectDeletion($account, $crmData);
return $account;
}
/**
* @inheritdoc
*/
public function syncOpportunities(array $parameters, ?string $strategy = null): int
{
$strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);
$syncCount = 0;
$logParams = $parameters;
$parameters['profile'] = $this->profile;
$logParams['user'] = $this->profile->getUserId();
if (count($strategies) > 1) {
$this->logger->warning('[' . $this->getDisplayName() . '] Multiple sync strategies used', [
'teamId' => $this->team->getUuid(),
'params' => $logParams,
'strategies_count' => count($strategies),
]);
}
foreach ($strategies as $syncStrategy) {
$name = $syncStrategy->getStrategyName();
try {
$sfOpportunities = $syncStrategy->fetchOpportunities($parameters);
$totalRecords = $sfOpportunities->count();
foreach ($sfOpportunities as $sfOpportunity) {
$this->importOpportunity($sfOpportunity);
$syncCount++;
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
$this->logger->warning('[' . $this->getDisplayName() . '] No opportunities found', [
'teamId' => $this->team->getUuid(),
'name' => $name,
'params' => $logParams,
'reason' => $noResultsException->getMessage(),
]);
} catch (CrmException $crmException) {
// Nothing to sync.
$this->logger->warning('[' . $this->getDisplayName() . '] Opportunity sync failed', [
'teamId' => $this->team->getUuid(),
'name' => $name,
'params' => $logParams,
'reason' => $crmException->getMessage(),
]);
}
}
$this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::OPPORTUNITY, ['params' => $logParams]);
// debug to see how if count of opportunities reaches 1000
if ($syncCount >= 1000) {
$this->logger->info(
'[' . $this->getDisplayName() . '] Sync Opportunities - count warning',
[
'team_id' => $this->team->getId(),
'params' => $logParams,
'count' => $syncCount,
'strategies_count' => count($strategies),
'total_records' => $totalRecords ?? null,
]
);
}
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncOpportunity(string $crmId): ?Opportunity
{
$strategy = $this->opportunitySyncStrategyResolver->resolve(
$this->config,
OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY
);
$parameters = [
'profile' => $this->profile,
'crm_id' => $crmId,
];
try {
$sfOpportunity = $strategy->fetchOpportunities($parameters);
} catch (HttpNotFoundException $e) {
$this->logger->info('[' . $this->getDisplayName() . '] Opportunity not found', [
'teamId' => $this->team->id_string,
'crmId' => $crmId,
]);
return null;
} catch (CrmException $crmException) {
$this->logger->info('[' . $this->getDisplayName() . '] Opportunity sync failed', [
'teamId' => $this->team->id_string,
'crmId' => $crmId,
'exception' => $crmException->getMessage(),
]);
return null;
}
if ($sfOpportunity instanceof ArrayIterator) {
return $this->importOpportunity($sfOpportunity->getItems());
}
return $this->importOpportunity($sfOpportunity);
}
/**
* @throws HttpNotFoundException
*/
private function importOpportunity($crmData): ?Opportunity
{
$profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);
$account = null;
if (empty($crmData['AccountId']) === false) {
/** @var ?Account $account */
$account = $this->config->accounts()
->where('crm_provider_id', (string) $crmData['AccountId'])
->first();
if ($account === null) {
$account = $this->syncAccount($crmData['AccountId']);
}
}
$userId = $profile?->getUserId() ?? $account?->getUserId();
if ($...
|
[{"role":"AXStaticText","text& [{"role":"AXStaticText","text":"Diff","depth":1,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Changelist:","depth":1,"on_screen":true,"role_description":"text"},{"role":"AXPopUpButton","text":"Changelist:","depth":1,"on_screen":true,"role_description":"pop up button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Changes","depth":6,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Show Diff","depth":2,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Rollback...","depth":2,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Refresh","depth":2,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Group By","depth":2,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand All","depth":2,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse All","depth":2,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"1 modified","depth":1,"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"JY-2096","depth":2,"on_screen":true,"value":"JY-2096","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Commit Message","depth":1,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Commit Message History","depth":2,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Git","depth":2,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Author:","depth":2,"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"Author:","depth":3,"on_screen":true,"role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Amend commit","depth":2,"on_screen":true,"help_text":"Merge this commit with the previous one","role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Sign-off commit","depth":2,"on_screen":true,"help_text":"<html>Adds the following line at the end of the commit message:<br/>Signed-off by: Lukas Kovalik <kovaliklukas@gmail.com></html>","role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Create extra commit with file movements","depth":2,"on_screen":false,"role_description":"checkbox","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Commit Checks","depth":2,"on_screen":true,"role_description":"text"},{"role":"AXCheckBox","text":"Update copyright","depth":2,"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Reformat code","depth":2,"on_screen":true,"help_text":"","role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Rearrange code","depth":2,"on_screen":true,"help_text":"","role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Optimize imports","depth":2,"on_screen":true,"help_text":"","role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Cleanup","depth":2,"on_screen":true,"help_text":"","role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"Choose profile","depth":2,"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Check malicious dependencies","depth":2,"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Run external formatter","depth":2,"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"Configure","depth":2,"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Perform SonarQube for IDE analysis","depth":2,"on_screen":true,"help_text":"","role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Find unused view files","depth":2,"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Run Git hooks","depth":2,"on_screen":false,"help_text":"If unchecked, Git hooks will be skipped with the '--no-verify' option for the upcoming commit","role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Advanced Commit Checks","depth":2,"on_screen":true,"role_description":"text"},{"role":"AXCheckBox","text":"Analyze code","depth":2,"on_screen":true,"help_text":"","role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"Choose profile","depth":2,"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Check TODO","depth":2,"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"Configure","depth":2,"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"After Commit","depth":2,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Upload files to:","depth":2,"on_screen":true,"role_description":"text"},{"role":"AXPopUpButton","text":"Upload files to:","depth":2,"on_screen":true,"role_description":"pop up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"<None>","depth":7,"on_screen":false,"role_description":"text"},{"role":"AXCheckBox","text":"Always use selected server or group of servers","depth":2,"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Previous Difference","depth":2,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Difference","depth":2,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Jump to Source","depth":2,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Compare Previous File","depth":2,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Compare Next File","depth":2,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Go to Changed File…","depth":2,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Side-by-side viewer","depth":2,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Do not ignore","depth":2,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Highlight words","depth":2,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse Unchanged Fragments","depth":2,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Synchronize Scrolling","depth":2,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Disable Editing","depth":2,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Settings","depth":2,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Help","depth":2,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"4 differences","depth":1,"on_screen":true,"role_description":"text"},{"role":"AXTextField","text":"1aee7aad","depth":1,"on_screen":true,"value":"1aee7aad","help_text":"text/plain","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Salesforce;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse Illuminate\\Database\\Eloquent\\Relations\\HasMany;\nuse Illuminate\\Events\\Dispatcher;\nuse Illuminate\\Support\\Facades\\Cache;\nuse Illuminate\\Support\\Facades\\Log;\nuse Illuminate\\Support\\Str;\nuse Jiminny\\Component\\Country\\CountriesMap;\nuse Jiminny\\Contracts\\Acl\\PermissionEnum;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\FetchRelatedActivityInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\ImportsBusinessProcessesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\SalesforceBatchSyncInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\SalesforceInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteNoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SearchTaskInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SendSummaryToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SupportsObjectTypeParseInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmProfileRecordTypesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Enums\\CrmObject;\nuse Jiminny\\Events\\Activities\\Crm\\LeadConverted;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpBadRequestException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Exceptions\\NoResultsException;\nuse Jiminny\\Exceptions\\ServiceUnavailableException;\nuse Jiminny\\Jobs\\Crm\\NoteObject;\nuse Jiminny\\Jobs\\Crm\\MatchActivitiesToNewOpportunity;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Calendar\\CalendarEvent;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Contracts\\ActivityContract;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Configuration;\nuse Jiminny\\Models\\Crm\\ContactRole;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Crm\\RecordType;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\TeamSettings;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\ContactRoleRepository;\nuse Jiminny\\Repositories\\Crm\\FieldRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Repositories\\Crm\\RecordTypeFieldValuesRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\Helpers\\ArrayIterator;\nuse Jiminny\\Services\\Crm\\MatchDomainByEmailInterface;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Services\\Crm\\ResolveCompanyNameByEmailTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\Fields\\FieldHelper;\nuse Jiminny\\Services\\Crm\\Salesforce\\Fields\\FieldTypeConverter;\nuse Jiminny\\Services\\Crm\\Salesforce\\Fields\\ValueNormalizer;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\FollowupActivityTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\LogActivityTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\RecordManipulationsTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\SyncFieldsTrait;\nuse Jiminny\\Utils\\CurrencyFormatter;\nuse Jiminny\\Utils\\StringUtil;\nuse Ramsey\\Uuid\\Uuid;\nuse Sentry\\Laravel\\Facade as Sentry;\n\nclass Service extends BaseService implements\n SalesforceInterface,\n SalesforceBatchSyncInterface,\n SyncCrmEntitiesInterface,\n SyncCrmProfileRecordTypesInterface,\n ImportsBusinessProcessesInterface,\n RemoteEntityManipulationInterface,\n FetchRelatedActivityInterface,\n SendSummaryToCrmInterface,\n MatchDomainByEmailInterface,\n SearchTaskInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n SupportsObjectTypeParseInterface,\n RemoteNoteEntityManipulationInterface,\n VerifyTaskExistsInterface\n{\n use ResolveCompanyNameByEmailTrait;\n use SyncFieldsTrait;\n use DeleteObjectsTrait;\n use RecordManipulationsTrait;\n use ServiceTraits\\BatchSyncTrait;\n use FollowupActivityTrait;\n use LogActivityTrait;\n\n /**\n * Note Body Limit for the Old Note-Taking Tool\n *\n * @var int\n */\n private const int CLASSIC_NOTE_MAX_LENGTH = 32000;\n\n /**\n * Note Content Limit for the New Notes\n *\n * @var int\n */\n private const int ENHANCED_NOTE_MAX_LENGTH = 50000000;\n\n private const string INSTALLED_PACKAGE_ID = '033Tw0000007bKbIAI';\n\n private const int CACHE_TTL = 600;\n\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day - 86400\n\n /**\n * @var Client\n */\n protected $client;\n\n protected PayloadBuilder $payloadBuilder;\n protected QueryHandler $queryHandler;\n\n private OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n\n public function __construct(\n Client $client,\n PayloadBuilder $payloadBuilder,\n protected Dispatcher $eventDispatcher,\n private readonly CountriesMap $countriesMap,\n private readonly ProspectPhotoPathService $prospectPhotoPathService,\n ) {\n parent::__construct();\n\n $this->client = $client;\n $this->payloadBuilder = $payloadBuilder;\n $this->queryHandler = app(QueryHandler::class, [\n 'client' => $this->client,\n 'logger' => $this->logger,\n ]);\n $this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n }\n\n public function getDisplayName(): string\n {\n return 'Salesforce';\n }\n\n public function getJobDelay(): int\n {\n return 1;\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n return $user->getSocialAccount(SocialAccount::PROVIDER_SALESFORCE);\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($activity, $crmProviderId) {\n $playbook = $this->getPlaybookFromActivity($activity);\n\n if ($playbook === null) {\n $this->logger->warning('[Salesforce] Cannot verify task - no playbook found', [\n 'activity' => $activity->getId(),\n 'crm_provider_id' => $crmProviderId,\n ]);\n\n return false;\n }\n\n $objectType = $playbook->getActivityType() === Playbook::ACTIVITY_TYPE_EVENT ? 'Event' : 'Task';\n\n try {\n $record = $this->getRecord($objectType, $crmProviderId, ['Id', 'IsDeleted']);\n\n return ! empty($record) && ($record['IsDeleted'] ?? false) === false;\n } catch (HttpNotFoundException|HttpBadRequestException) {\n $this->logger->info('[Salesforce] Activity record not found during verification', [\n 'activity' => $activity->getId(),\n 'object_type' => $objectType,\n 'crm_provider_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n });\n }\n\n public function query(string $queryToRun, array $parameters = []): QueryIterator\n {\n // Due to poorly designed external calls, this method cannot be entirely removed\n return $this->queryHandler->query($queryToRun, $parameters);\n }\n\n /*=========== Organization Information ===============*/\n\n /**\n * Get a list of all the API Versions for the instance.\n *\n * @throws CrmException\n *\n * @return mixed\n *\n */\n public function getApiVersions()\n {\n $url = $this->config->crm_base_url . '/services/data';\n\n $response = $this->client->get($url);\n\n return json_decode($response->getBody(), true);\n }\n\n /**\n * Gets the valid recordTypes for a given Salesforce Object via the describe API.\n */\n private function getRecordTypes(string $crmObject): array\n {\n $url = $this->client->getObjectsUrl() . $crmObject . '/describe';\n\n $response = $this->client->get($url);\n $jsonResponse = json_decode($response->getBody(), true);\n\n $fields = [];\n foreach ($jsonResponse['recordTypeInfos'] as $row) {\n $fields[] = ['recordTypeId' => $row['recordTypeId'], 'default' => $row['defaultRecordTypeMapping']];\n }\n\n return $fields;\n }\n\n /**\n * Convert raw field data into a format compatible with CRM APIs.\n */\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return ValueNormalizer::normalize($fieldType, $fieldValue, $internal);\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n $defaultFields = ($activityType === Playbook::ACTIVITY_TYPE_TASK)\n ? FieldDefinitions::defaultTaskFields()\n : FieldDefinitions::defaultEventFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n // Setup the activity field as the default Type.\n /** @var Field $activityField */\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'Type',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK, Playbook::ACTIVITY_TYPE_EVENT];\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n\n $fieldFilter = ($activityType === Playbook::ACTIVITY_TYPE_TASK)\n ? FieldDefinitions::taskFollowupFieldsFilter()\n : FieldDefinitions::eventFollowupFieldsFilter();\n\n foreach ($fieldFilter as $eachFilter) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $eachFilter);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n /**\n * This one is now called only when ImportActivityTypes is triggered or SyncFieldMetadata executed manually\n * Regular sync now uses SharedSyncFieldsTrait -> syncSingleObjectType\n * Needs to be replaced later on\n */\n public function syncField(Field $field): void\n {\n try {\n if (FieldHelper::isCustomField($field)) {\n $query = '\n SELECT\n Id, Metadata, TableEnumOrId\n FROM\n CustomField\n WHERE\n DeveloperName = :fieldName\n AND\n TableEnumOrId = :fieldType\n AND\n NamespacePrefix = :namespacePrefix';\n\n // We need to constrain the field lookup to the object, in case it's used in multiple places.\n $objectType = \\in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true)\n ? 'activity'\n : $field->object_type;\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'fieldName' => substr($field->crm_provider_id, 0, -\\strlen('__c')),\n 'fieldType' => ucfirst($objectType),\n\n // This is used to ensure we only consider the field within the org, not installed packages.\n 'namespacePrefix' => 'null',\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n // Sync field metadata.\n $metadata = $sfField['Metadata'];\n\n $field->description = mb_strimwidth($metadata['description'] ?? '', 0, 191);\n $field->label = mb_strimwidth($metadata['label'] ?? '', 0, Field::LABEL_MAX_LENGTH);\n $field->type = $this->convertFieldType($metadata['type'], $field->getEntityName());\n $field->is_mandatory = ($metadata['required'] === true);\n $field->length = $metadata['length'];\n $field->default_value = mb_strimwidth(trim($metadata['defaultValue'] ?? '', '\"'), 0, 191);\n $field->save();\n } else {\n $query = '\n SELECT\n Id, DataType, DeveloperName, Label, Length, Description\n FROM\n FieldDefinition\n WHERE\n DurableId = :entityName';\n\n $entityName = $field->getEntityName();\n $sfFields = $this->queryHandler->metadata($query, [\n 'entityName' => $entityName,\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n $convertedType = $this->convertFieldType($sfField['DataType'], $entityName);\n $label = mb_strimwidth($sfField['Label'], 0, Field::LABEL_MAX_LENGTH);\n\n if ($field->isBusinessType()) {\n $label = 'Opportunity Type';\n }\n\n $field->description = mb_strimwidth($sfField['Description'], 0, Field::DESCRIPTION_MAX_LENGTH);\n $field->label = $label;\n $field->type = $convertedType;\n $field->length = $sfField['Length'];\n $field->save();\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n private function convertFieldType(string $from, ?string $entityName = null): string\n {\n $converter = new FieldTypeConverter();\n\n return $converter->convert($from, $entityName);\n }\n\n /**\n * @inheritdoc\n */\n public function importPicklistValues(Field $field): array\n {\n $values = [];\n $fieldValues = [];\n\n try {\n if (FieldHelper::isCustomField($field)) {\n $query = '\n SELECT\n Id, Metadata, TableEnumOrId\n FROM\n CustomField\n WHERE\n DeveloperName = :fieldName\n AND\n TableEnumOrId = :fieldType\n AND\n NamespacePrefix = :namespacePrefix';\n\n // We need to constrain the field lookup to the object, in case it's used in multiple places.\n $objectType = \\in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true) ?\n 'activity' : $field->object_type;\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'fieldName' => substr($field->crm_provider_id, 0, -\\strlen('__c')),\n 'fieldType' => ucfirst($objectType),\n // This is used to ensure we only consider the field within the org, not installed packages.\n 'namespacePrefix' => 'null',\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n $valueSet = $sfField['Metadata']['valueSet'];\n\n if ($valueSet['valueSetName'] === null) {\n // Local picklist values can be obtained easily.\n $picklistValues = $valueSet['valueSetDefinition']['value'];\n } else {\n // But for some fields, we just get the Global Value Picklist pointer so need to do more work.\n $picklistValues = $this->importGlobalValuePicklistValues($valueSet['valueSetName']);\n }\n\n // Import all active values.\n foreach ($picklistValues as $i => $sfFieldValue) {\n // Setup default value.\n if ($sfFieldValue['default']) {\n $field->update(['default_value' => $sfFieldValue['valueName']]);\n }\n\n // This comes through as null if active (lol).\n if ($sfFieldValue['isActive'] !== false) {\n $values[] = [\n 'value' => $sfFieldValue['valueName'],\n 'label' => $sfFieldValue['valueName'],\n 'sequence' => $i,\n 'is_default' => $sfFieldValue['default'],\n ];\n }\n }\n } else {\n $objectFields = $this->getObjectFields($field->object_type);\n $fieldId = $field->crm_provider_id;\n\n // Only work with our field of interest.\n $objectField = array_filter($objectFields, function ($item) use ($fieldId) {\n return $item['name'] === $fieldId;\n });\n\n $objectField = array_shift($objectField);\n if (empty($objectField['picklistValues']) === false) {\n foreach ($objectField['picklistValues'] as $i => $sfFieldValue) {\n // Skip inactive values.\n if ($sfFieldValue['active'] === false) {\n continue;\n }\n\n // Setup default value.\n if ($sfFieldValue['defaultValue']) {\n $field->update(['default_value' => $sfFieldValue['value']]);\n }\n\n $values[] = [\n 'value' => $sfFieldValue['value'],\n 'label' => $sfFieldValue['label'],\n 'sequence' => $i,\n 'is_default' => $sfFieldValue['defaultValue'],\n ];\n }\n }\n }\n\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n $value['value'] = substr($value['value'] ?? '', 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n if (($key = array_search($value['value'], $fieldsToPurge, true)) !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n // Get IDs of the values to be deleted\n $valuesToDelete = $field->values()->whereIn('value', $fieldsToPurge);\n $valuesToDeleteIds = $valuesToDelete->pluck('id');\n if (! $valuesToDeleteIds->isEmpty()) {\n $recordTypeFieldValuesRepository = app(RecordTypeFieldValuesRepository::class);\n $recordTypeFieldValuesRepository->deleteForCrmFieldValueIds($valuesToDeleteIds->toArray());\n\n // Now safely delete from crm_field_values\n $valuesToDelete->delete();\n }\n\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n return $fieldValues;\n }\n\n /**\n * Gets values from Global Value Picklists.\n */\n private function importGlobalValuePicklistValues(string $picklistName): array\n {\n $query = '\n SELECT\n Metadata\n FROM\n GlobalValueSet\n WHERE\n DeveloperName = :picklistName\n LIMIT 1';\n\n try {\n $sfValues = $this->queryHandler->metadata($query, [\n 'picklistName' => $picklistName,\n ]);\n\n // There is always 1 result at this point.\n $sfValue = $sfValues->current();\n\n return $sfValue['Metadata']['customValue'];\n } catch (NoResultsException $noResultsException) {\n // Nothing returned.\n\n return [];\n }\n }\n\n /**\n * @inheritdoc\n */\n public function syncProfileRecordTypes(): void\n {\n $objectTypes = [\n 'lead',\n 'account',\n 'contact',\n 'opportunity',\n 'task',\n 'event',\n ];\n\n foreach ($objectTypes as $objectType) {\n try {\n $crmRecordTypes = $this->getRecordTypes(ucfirst($objectType));\n\n foreach ($crmRecordTypes as $crmRecordType) {\n // If the record type is default and not the Master type, set this.\n if ($crmRecordType['default'] && $crmRecordType['recordTypeId'] !== '012000000000000AAA') {\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $crmRecordType['recordTypeId'])\n ->first();\n\n if ($recordType) {\n $this->profile->{$objectType . '_record_type_id'} = $recordType->id;\n }\n }\n }\n } catch (HttpNotFoundException $exception) {\n Log::error('No access to ' . $objectType . ' object, skipping...');\n\n // XXX: should we log this fact somewhere?\n continue;\n }\n }\n\n if ($this->profile->isDirty()) {\n $this->profile->save();\n }\n }\n\n /**\n * Gets business processes.\n */\n public function importBusinessProcesses(): void\n {\n $query = '\n SELECT\n Id, IsActive, Name, TableEnumOrId\n FROM\n BusinessProcess\n WHERE\n TableEnumOrId IN (\\'Lead\\',\\'Opportunity\\')';\n\n try {\n $sfProcesses = $this->queryHandler->query($query);\n\n // Upsert all processes for the team.\n foreach ($sfProcesses as $sfProcess) {\n /** @var BusinessProcess $businessProcess */\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $sfProcess['Id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => $sfProcess['Name'],\n 'type' => $sfProcess['TableEnumOrId'] === 'Lead' ? 'lead' : 'opportunity',\n 'is_selectable' => $sfProcess['IsActive'],\n ]);\n\n $this->importBusinessProcessStages($businessProcess);\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n /**\n * Gets business process stages.\n */\n private function importBusinessProcessStages(BusinessProcess $businessProcess): void\n {\n $query = '\n SELECT\n Metadata\n FROM\n BusinessProcess\n WHERE\n Id = :processId';\n\n try {\n $stages = [];\n $sfProcessStages = $this->queryHandler->metadata($query, [\n 'processId' => $businessProcess->crm_provider_id,\n ]);\n\n // There is always 1 result at this point.\n $sfProcessStage = $sfProcessStages->current();\n\n // Upsert all processes for the team.\n foreach ($sfProcessStage['Metadata']['values'] as $sfProcessStage) {\n $sanitizedName = urldecode($sfProcessStage['valueName']); // Must decode: \"%2C\" becomes \",\" etc.\n\n $stage = $businessProcess->crm->stages()\n // This MUST match on label because this API doesn't use API Name.\n ->where('label', $sanitizedName)\n ->where('type', $businessProcess->type)\n ->where('is_selectable', 1)\n ->first();\n\n if ($stage) {\n $stages[] = $stage->id;\n }\n }\n\n $businessProcess->stages()->sync($stages);\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n /**\n * Gets record types.\n */\n public function importRecordTypes(): void\n {\n $query = '\n SELECT\n Id, IsActive, Name, BusinessProcessId, SobjectType\n FROM\n RecordType';\n\n try {\n $sfRecordTypes = $this->queryHandler->query($query);\n\n // Upsert all record types for the process.\n foreach ($sfRecordTypes as $sfRecordType) {\n $businessProcess = null;\n if ($sfRecordType['BusinessProcessId']) {\n $businessProcess = $this->config->businessProcesses()\n ->where('crm_provider_id', $sfRecordType['BusinessProcessId'])\n ->first();\n }\n\n /** @var RecordType $recordType */\n $recordType = $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $sfRecordType['Id'],\n ], [\n 'team_id' => $this->team->id,\n 'type' => mb_strtolower($sfRecordType['SobjectType']),\n 'name' => $sfRecordType['Name'],\n 'is_selectable' => $sfRecordType['IsActive'],\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n $this->importRecordTypeFieldValues($recordType);\n }\n } catch (NoResultsException $noResultsException) {\n // Do nothing.\n }\n }\n\n /**\n * Import record type - field value mappings. This only works for standard fields.\n */\n private function importRecordTypeFieldValues(RecordType $recordType): void\n {\n try {\n $query = '\n SELECT\n Metadata\n FROM\n RecordType\n WHERE\n Id = :recordTypeId';\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'recordTypeId' => $recordType->crm_provider_id,\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n // Sync field metadata.\n $picklists = $sfField['Metadata']['picklistValues'];\n\n foreach ($picklists as $picklist) {\n $field = $this->config->fields()->where([\n 'type' => Field::TYPE_PICKLIST,\n 'object_type' => $recordType->type,\n 'crm_provider_id' => $picklist['picklist'],\n ])->first();\n\n if ($field) {\n $fieldValues = [];\n\n foreach ($picklist['values'] as $value) {\n // Must decode: \"%2C\" becomes \",\" etc.\n $fieldValue = $field->values()\n ->where('value', urldecode($value['valueName']))\n ->first();\n\n if ($fieldValue) {\n $fieldValues[] = $fieldValue->id;\n }\n }\n\n $recordType->fieldValues()->sync($fieldValues);\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n /**\n * @inheritdoc\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $params = [];\n $missingStage = null;\n if ($types === null) {\n $types = [Stage::TYPE_LEAD, Stage::TYPE_OPPORTUNITY];\n }\n\n foreach ($types as $type) {\n if ($type === Stage::TYPE_LEAD) {\n $query = '\n SELECT\n Id, ApiName, MasterLabel, SortOrder\n FROM\n LeadStatus';\n } else {\n $query = '\n SELECT\n Id, ApiName, MasterLabel, IsActive, SortOrder, DefaultProbability\n FROM\n OpportunityStage';\n }\n\n if ($missingStageName) {\n $escapedStageName = ValueNormalizer::replaceQueryWithStringLiterals($missingStageName);\n\n $query .= ' WHERE ApiName = :stageName';\n\n $params = [\n 'stageName' => $escapedStageName,\n ];\n }\n\n try {\n $sfStages = $this->queryHandler->query($query, $params);\n } catch (NoResultsException $exception) {\n $sfStages = [];\n }\n\n $missingStage = null;\n\n // Upsert all stages for the team.\n foreach ($sfStages as $sfStage) {\n $selectable = true;\n if (array_key_exists('IsActive', $sfStage)) {\n $selectable = $sfStage['IsActive'];\n }\n\n $this->restoreAnyTrashedEntity($this->config->stages(), $sfStage['Id']);\n\n $stage = $this->config->stages()->updateOrCreate([\n 'crm_provider_id' => $sfStage['Id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($sfStage['ApiName'], 0, 50),\n 'label' => mb_strimwidth($sfStage['MasterLabel'], 0, 191),\n 'type' => $type,\n 'sequence' => $sfStage['SortOrder'] ?? 0,\n 'is_selectable' => $selectable,\n 'probability' => $sfStage['DefaultProbability'] ?? null,\n ]);\n\n if ($missingStageName && $missingStageName === $sfStage['ApiName']) {\n $missingStage = $stage;\n }\n }\n\n if ($missingStageName && $missingStage === null) {\n // If they requested a stage that still doesn't exist, it must be inactive so lazy create it.\n $missingStage = $this->config->stages()->create([\n 'crm_provider_id' => Uuid::uuid4(),\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($missingStageName, 0, 50),\n 'label' => mb_strimwidth($missingStageName, 0, 191),\n 'type' => $type,\n 'sequence' => 0,\n 'is_selectable' => 0,\n ]);\n }\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int\n {\n $syncCount = 0;\n $fields = $this->getAllFieldsAsArray('lead');\n if (\\in_array('Id', $fields, true) === false) {\n return $syncCount;\n }\n\n $query = '\n SELECT ' . rtrim(implode(',', $fields), ',') . '\n FROM Lead\n WHERE LastModifiedDate > :since\n ORDER BY LastModifiedDate ASC';\n\n try {\n $sfLeads = $this->queryHandler->query($query, [\n 'since' => $since->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($sfLeads as $sfLead) {\n // Only sync if previously imported.\n if ($this->hasLead($sfLead['Id'])) {\n $this->importLead($sfLead);\n $syncCount++;\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::LEAD);\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLead(string $crmId): ?Lead\n {\n $fields = $this->getAllFieldsAsArray('lead');\n\n $sfLead = $this->getRecord('Lead', $crmId, $fields);\n\n return $this->importLead($sfLead);\n }\n\n private function importLead($crmData): ?Lead\n {\n /** @var ?Stage $stage */\n $stage = null;\n if (isset($crmData['Status'])) {\n // Get the current stage.\n $stage = $this->config\n ->stages()\n ->where('name', $crmData['Status'])\n ->where('type', Stage::TYPE_LEAD)\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_LEAD], $crmData['Status']);\n }\n }\n\n // If we have no way of importing this, just return null :(\n if ($stage === null) {\n return null;\n }\n\n $countryCode = $crmData['CountryCode'] ?? null;\n\n // Salesforce allows custom \"countries\" to be created. Disregard these.\n if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {\n $countryCode = null;\n }\n\n // If we have no country code, try to parse it from the country name.\n if ($countryCode === null && empty($crmData['Country']) !== false) {\n $countryCode = $this->convertCountryNameToCode($crmData['Country']);\n }\n\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($crmData['Phone'] ?? '', 0, 25);\n $parsedNumber = parsePhoneNumber($countryCode, $number);\n\n $mobilePhone = null;\n if (empty($crmData['MobilePhone']) === false) {\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($crmData['MobilePhone'], 0, 25);\n $mobilePhone = phone_e164($countryCode, $number);\n }\n\n $convertedDate = null;\n $convertedAccount = null;\n $convertedOpportunity = null;\n $convertedContact = null;\n\n if ($crmData['IsConverted'] == 'true') {\n $convertedDate = $crmData['ConvertedDate'];\n\n if (empty($crmData['ConvertedAccountId']) === false) {\n $convertedAccount = $this->config\n ->accounts()\n ->where('crm_provider_id', $crmData['ConvertedAccountId'])\n ->first();\n\n if ($convertedAccount === null) {\n try {\n $convertedAccount = $this->syncAccount($crmData['ConvertedAccountId']);\n } catch (HttpNotFoundException $exception) {\n // Probably the user has no permissions to access the converted data.\n }\n }\n }\n\n if (empty($crmData['ConvertedOpportunityId']) === false) {\n $convertedOpportunity = $this->config\n ->opportunities()\n ->where('crm_provider_id', $crmData['ConvertedOpportunityId'])\n ->first();\n\n if ($convertedOpportunity === null) {\n try {\n $convertedOpportunity = $this->syncOpportunity($crmData['ConvertedOpportunityId']);\n } catch (HttpNotFoundException $exception) {\n // Probably the user has no permissions to access the converted data.\n }\n }\n }\n\n if (empty($crmData['ConvertedContactId']) === false) {\n $convertedContact = $this->team\n ->crm\n ->contacts()\n ->where('crm_provider_id', $crmData['ConvertedContactId'])\n ->first();\n\n if ($convertedContact === null) {\n try {\n $convertedContact = $this->syncContact($crmData['ConvertedContactId']);\n } catch (HttpNotFoundException $exception) {\n // Probably the user has no permissions to access the converted data.\n }\n }\n }\n }\n\n if (empty($crmData['Company'])) {\n $company = 'Unknown';\n } else {\n $company = mb_strimwidth($crmData['Company'], 0, 191);\n }\n\n $domain = null;\n if (empty($crmData['Website']) === false) {\n $domain = mb_strimwidth($crmData['Website'], 0, 191);\n $domain = StringUtil::resolveDomain($domain);\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $data = [\n 'team_id' => $this->team->id,\n 'user_id' => $profile?->user_id,\n 'owner_id' => $crmData['OwnerId'] ?? '',\n 'company' => $company,\n 'domain' => $domain,\n 'name' => $crmData['Name'] ? mb_strimwidth($crmData['Name'], 0, 191) : '',\n 'title' => $crmData['Title'] ? mb_strimwidth($crmData['Title'], 0, 128) : null,\n 'email' => $crmData['Email'] ? mb_strimwidth($crmData['Email'], 0, 80) : null,\n 'phone' => $parsedNumber['phone'],\n 'ext' => $parsedNumber['ext'] ?? null,\n 'mobile_phone' => $mobilePhone,\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['Id'],\n modelType: Lead::class,\n fileName: $crmData['Id'],\n avatarText: $crmData['Name']\n ),\n 'stage_id' => $stage->id,\n 'record_type_id' => null,\n 'converted_at' => $convertedDate,\n 'converted_account_id' => $convertedAccount->id ?? null,\n 'converted_opportunity_id' => $convertedOpportunity->id ?? null,\n 'converted_contact_id' => $convertedContact->id ?? null,\n 'country_code' => $countryCode,\n 'remotely_created_at' => $createdDate,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->leads(), $crmData['Id']);\n\n /** @var Lead $lead */\n $lead = $this->config->leads()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n if ($lead->wasChanged('converted_at') && $lead->getConvertedAt() !== null) {\n $this->eventDispatcher->dispatch(new LeadConverted($lead));\n }\n\n $this->handleObjectDeletion($lead, $crmData);\n\n return $lead;\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccounts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n $fields = $this->getAllFieldsAsArray('account');\n\n if (\\in_array('Id', $fields, true) === false) {\n return $syncCount;\n }\n\n $query = '\n SELECT ' . rtrim(implode(',', $fields), ',') . '\n FROM Account\n WHERE LastModifiedDate > :since\n ORDER BY LastModifiedDate ASC';\n\n try {\n $sfAccounts = $this->queryHandler->query($query, [\n 'since' => $since->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($sfAccounts as $sfAccount) {\n // Only sync if previously imported.\n if ($this->hasAccount($sfAccount['Id'])) {\n $this->importAccount($sfAccount);\n $syncCount++;\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::ACCOUNT);\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccount(string $crmId): ?Account\n {\n $fields = $this->getAllFieldsAsArray('account');\n if (! in_array('Id', $fields, true)) {\n $this->logger->info('[Salesforce] Sync account cancelled. Fields are not available.', [\n 'crmId' => $crmId,\n 'userId' => $this->profile->getUserId(),\n ]);\n\n return null;\n }\n\n $sfAccount = $this->getRecord('Account', $crmId, $fields);\n\n return $this->importAccount($sfAccount);\n }\n\n private function importAccount($crmData): Account\n {\n $countryCode = $crmData['BillingCountryCode'] ?? $crmData['ShippingCountryCode'] ?? null;\n\n // Salesforce allows custom \"countries\" to be created. Disregard these.\n if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {\n $countryCode = null;\n }\n\n // If we have no country code, try to parse it from the country names.\n if ($countryCode === null && empty($crmData['BillingCountry']) === false) {\n $countryCode = $this->convertCountryNameToCode($crmData['BillingCountry']);\n }\n\n if ($countryCode === null && empty($crmData['ShippingCountry']) === false) {\n $countryCode = $this->convertCountryNameToCode($crmData['ShippingCountry']);\n }\n\n if (empty($crmData['Phone']) === false) {\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($crmData['Phone'], 0, 25);\n $parsedNumber = parsePhoneNumber($countryCode, $number);\n } else {\n $parsedNumber = [];\n }\n\n $industry = null;\n if (empty($crmData['Industry']) === false) {\n $industry = mb_strimwidth($crmData['Industry'], 0, 40);\n }\n\n $domain = null;\n if (empty($crmData['Website']) === false) {\n $domain = mb_strimwidth($crmData['Website'], 0, 191);\n $domain = StringUtil::resolveDomain($domain);\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $data = [\n 'team_id' => $this->team->id,\n 'user_id' => $profile?->user_id,\n 'owner_id' => $crmData['OwnerId'],\n 'name' => mb_strimwidth($crmData['Name'], 0, 191),\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['Id'],\n modelType: Account::class,\n fileName: $crmData['Id'],\n avatarText: $crmData['Name']\n ),\n 'industry' => $industry,\n 'domain' => $domain,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'ext' => $parsedNumber['ext'] ?? null,\n 'country_code' => $countryCode,\n 'remotely_created_at' => $createdDate,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->accounts(), $crmData['Id']);\n\n /** @var Account $account */\n $account = $this->config->accounts()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n $this->handleObjectDeletion($account, $crmData);\n\n return $account;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOpportunities(array $parameters, ?string $strategy = null): int\n {\n $strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);\n\n $syncCount = 0;\n $logParams = $parameters;\n $parameters['profile'] = $this->profile;\n $logParams['user'] = $this->profile->getUserId();\n\n if (count($strategies) > 1) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Multiple sync strategies used', [\n 'teamId' => $this->team->getUuid(),\n 'params' => $logParams,\n 'strategies_count' => count($strategies),\n ]);\n }\n\n foreach ($strategies as $syncStrategy) {\n $name = $syncStrategy->getStrategyName();\n\n try {\n $sfOpportunities = $syncStrategy->fetchOpportunities($parameters);\n $totalRecords = $sfOpportunities->count();\n\n foreach ($sfOpportunities as $sfOpportunity) {\n $this->importOpportunity($sfOpportunity);\n $syncCount++;\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n $this->logger->warning('[' . $this->getDisplayName() . '] No opportunities found', [\n 'teamId' => $this->team->getUuid(),\n 'name' => $name,\n 'params' => $logParams,\n 'reason' => $noResultsException->getMessage(),\n ]);\n } catch (CrmException $crmException) {\n // Nothing to sync.\n $this->logger->warning('[' . $this->getDisplayName() . '] Opportunity sync failed', [\n 'teamId' => $this->team->getUuid(),\n 'name' => $name,\n 'params' => $logParams,\n 'reason' => $crmException->getMessage(),\n ]);\n }\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::OPPORTUNITY, ['params' => $logParams]);\n\n // debug to see how if count of opportunities reaches 1000\n if ($syncCount >= 1000) {\n $this->logger->info(\n '[' . $this->getDisplayName() . '] Sync Opportunities - count warning',\n [\n 'team_id' => $this->team->getId(),\n 'params' => $logParams,\n 'count' => $syncCount,\n 'strategies_count' => count($strategies),\n 'total_records' => $totalRecords ?? null,\n ]\n );\n }\n\n return $syncCount;\n }\n\n\n /**\n * @inheritdoc\n */\n public function syncOpportunity(string $crmId): ?Opportunity\n {\n $strategy = $this->opportunitySyncStrategyResolver->resolve(\n $this->config,\n OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY\n );\n\n $parameters = [\n 'profile' => $this->profile,\n 'crm_id' => $crmId,\n ];\n\n try {\n $sfOpportunity = $strategy->fetchOpportunities($parameters);\n } catch (HttpNotFoundException $e) {\n $this->logger->info('[' . $this->getDisplayName() . '] Opportunity not found', [\n 'teamId' => $this->team->id_string,\n 'crmId' => $crmId,\n ]);\n\n return null;\n } catch (CrmException $crmException) {\n $this->logger->info('[' . $this->getDisplayName() . '] Opportunity sync failed', [\n 'teamId' => $this->team->id_string,\n 'crmId' => $crmId,\n 'exception' => $crmException->getMessage(),\n ]);\n\n return null;\n }\n\n if ($sfOpportunity instanceof ArrayIterator) {\n return $this->importOpportunity($sfOpportunity->getItems());\n }\n\n return $this->importOpportunity($sfOpportunity);\n }\n\n /**\n * @throws HttpNotFoundException\n */\n private function importOpportunity($crmData): ?Opportunity\n {\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $account = null;\n if (empty($crmData['AccountId']) === false) {\n /** @var ?Account $account */\n $account = $this->config->accounts()\n ->where('crm_provider_id', (string) $crmData['AccountId'])\n ->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmData['AccountId']);\n }\n }\n\n $userId = $profile?->getUserId() ?? $account?->getUserId();\n if ($userId === null) {\n $this->logger->error('[Salesforce] | Skip import, no user_id found', [\n 'id' => $crmData['Id'],\n ]);\n\n return null;\n }\n\n /** @var ?Stage $stage */\n $stage = null;\n if (isset($crmData['StageName'])) {\n $stage = $this->config\n ->stages()\n ->where('name', $crmData['StageName'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->orderBy('is_selectable', 'DESC')\n ->orderBy('id')\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $crmData['StageName']);\n }\n }\n\n $recordType = null;\n if (empty($crmData['RecordTypeId']) === false) {\n /** @var ?RecordType $recordType */\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $crmData['RecordTypeId'])\n ->first();\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $closeDate = null;\n if (empty($crmData['CloseDate']) === false) {\n $closeDate = Carbon::parse($crmData['CloseDate'])->format('Y-m-d');\n }\n\n $valueFieldName = 'Amount';\n if ($this->config->opportunity_value_field_id) {\n $valueFieldName = $this->config->opportunityValueField->crm_provider_id;\n }\n\n $data = [\n 'team_id' => $this->team->id,\n 'account_id' => $account->id ?? null,\n 'user_id' => $userId,\n 'owner_id' => $crmData['OwnerId'] ?? null,\n 'name' => mb_strimwidth($crmData['Name'] ?? '', 0, 128),\n 'value' => $crmData[$valueFieldName],\n 'currency_code' => CurrencyFormatter::formatCode($crmData['CurrencyIsoCode'] ?? null),\n 'close_date' => $closeDate,\n 'is_closed' => $crmData['IsClosed'],\n 'is_won' => $crmData['IsWon'],\n 'stage_id' => $stage?->id ?? null,\n 'record_type_id' => $recordType->id ?? null,\n 'remotely_created_at' => $createdDate,\n 'probability' => $crmData['Probability'] ?? null,\n 'forecast_category' => $crmData['ForecastCategoryName'] ?? null,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->opportunities(), $crmData['Id']);\n\n // Do not allow locked DB tables & other errors\n // to interrupt the process of reverting the trashed opportunities\n try {\n /** @var Opportunity $opportunity */\n $opportunity = $this->config->opportunities()\n ->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n // import external fields into crm_field_data if present\n $crmFields = $this->getOpportunitySyncableFields();\n\n $this->importOpportunityCrmFieldData($crmData, $crmFields, $opportunity->id);\n\n $this->handleObjectDeletion($opportunity, $crmData);\n\n if ($opportunity->wasRecentlyCreated) {\n MatchActivitiesToNewOpportunity::dispatch($opportunity->getId());\n }\n\n return $opportunity;\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n $this->logger->error('[Salesforce] importOpportunity failure.', [\n 'crm_provider_id' => $crmData['Id'],\n 'team_id' => $this->team->id,\n 'exception' => $exception->getMessage(),\n ]);\n\n $this->handleEntityDeletionByProviderId($this->config->opportunities(), $crmData);\n }\n\n return null;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContacts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n $fields = $this->getAllFieldsAsArray('contact');\n if (\\in_array('Id', $fields, true) === false) {\n return $syncCount;\n }\n\n $query = '\n SELECT ' . rtrim(implode(',', $fields), ',') . '\n FROM Contact\n WHERE LastModifiedDate > :since\n ORDER BY LastModifiedDate ASC';\n\n try {\n $sfContacts = $this->queryHandler->query($query, [\n 'since' => $since->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($sfContacts as $sfContact) {\n // Only sync if previously imported.\n if ($this->hasContact($sfContact['Id'])) {\n $this->importContact($sfContact);\n $syncCount++;\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::CONTACT);\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContact(string $crmId): ?Contact\n {\n $fields = $this->getAllFieldsAsArray('contact');\n if (! in_array('Id', $fields, true)) {\n $this->logger->info('[Salesforce] Sync contact cancelled. Fields are not available.', [\n 'crmId' => $crmId,\n 'userId' => $this->profile->getUserId(),\n ]);\n\n return null;\n }\n\n $sfContact = $this->getRecord('Contact', $crmId, $fields);\n\n return $this->importContact($sfContact);\n }\n\n private function importContact($crmData): Contact\n {\n $account = null;\n // Contacts may not have accounts...\n if (isset($crmData['AccountId'])) {\n $account = $this->config->accounts()\n ->where('crm_provider_id', (string) $crmData['AccountId'])\n ->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmData['AccountId']);\n }\n }\n\n $countryCode = $crmData['MailingCountryCode'] ?? null;\n\n // Salesforce allows custom \"countries\" to be created. Disregard these.\n if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {\n $countryCode = null;\n }\n\n // If we have no country code, try to parse it from the country name.\n if ($countryCode === null && empty($crmData['MailingCountry']) === false) {\n $countryCode = $this->convertCountryNameToCode($crmData['MailingCountry']);\n\n if ($countryCode === null && $account) {\n $countryCode = $account->country_code;\n }\n }\n\n $ext = null;\n $parsedNumber = [];\n if (empty($crmData['Phone']) === false) {\n $number = Str::limit($crmData['Phone'], 25, '');\n $parsedNumber = parsePhoneNumber($countryCode, $number);\n\n if (empty($parsedNumber['ext']) === false) {\n $ext = Str::limit($parsedNumber['ext'], 10, '');\n }\n }\n\n $mobileNumber = null;\n if (empty($crmData['MobilePhone']) === false) {\n $mobileNumber = Str::limit(phone_e164($countryCode, $crmData['MobilePhone']), 25, '');\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $data = [\n 'team_id' => $this->team->id,\n 'account_id' => $account->id ?? null,\n 'user_id' => $profile?->user_id,\n 'owner_id' => $crmData['OwnerId'] ?? null,\n 'name' => ($crmData['Name'] ?? null) !== null ? mb_strimwidth($crmData['Name'], 0, 100) : '',\n 'title' => ($crmData['Title'] ?? null) !== null ? mb_strimwidth($crmData['Title'], 0, 128) : null,\n 'email' => ($crmData['Email'] ?? null) !== null ? mb_strimwidth($crmData['Email'], 0, 191) : null,\n 'country_code' => $countryCode,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'ext' => $ext,\n 'mobile_phone' => $mobileNumber,\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['Id'],\n modelType: Contact::class,\n fileName: $crmData['Id'],\n avatarText: $crmData['Name']\n ),\n 'remotely_created_at' => $createdDate,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->contacts(), $crmData['Id']);\n\n /** @var Contact $contact */\n $contact = $this->config->contacts()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n $this->handleObjectDeletion($contact, $crmData);\n\n return $contact;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n $fields = [\n 'InstanceName',\n 'OrganizationType',\n 'IsSandbox',\n ];\n\n $orgValues = $this->getRecord('Organization', $this->config->crm_provider_id, $fields);\n\n $edition = null;\n switch ($orgValues['OrganizationType']) {\n case 'Developer Edition':\n $edition = Configuration::EDITION_DEVELOPER;\n\n break;\n\n case 'Professional Edition':\n $edition = Configuration::EDITION_PROFESSIONAL;\n\n break;\n\n case 'Enterprise Edition':\n $edition = Configuration::EDITION_ENTERPRISE;\n\n break;\n }\n\n $this->config->edition = $edition;\n $this->config->instance = $orgValues['InstanceName'];\n\n // XXX: How can this state be possible?\n if ($this->config->version === null) {\n $this->config->version = Client::MIN_API_VERSION;\n }\n\n $installedVersion = $this->getInstalledAppVersion();\n if ($installedVersion !== null) {\n $installedVersion = (string) $this->getInstalledAppVersion();\n }\n\n $this->config->installed_app_version = $installedVersion;\n\n $this->config->save();\n }\n\n public function getInstalledAppVersion(): ?string\n {\n try {\n $query = '\n SELECT\n SubscriberPackageVersion.MajorVersion,\n SubscriberPackageVersion.MinorVersion,\n SubscriberPackageVersion.PatchVersion,\n SubscriberPackageVersion.BuildNumber\n FROM\n InstalledSubscriberPackage\n WHERE\n SubscriberPackageId = :packageId\n ';\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'packageId' => self::INSTALLED_PACKAGE_ID,\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n // Grab version number.\n $version = $sfField['SubscriberPackageVersion']['MajorVersion'] .\n $sfField['SubscriberPackageVersion']['MinorVersion'] .\n $sfField['SubscriberPackageVersion']['PatchVersion'] .\n $sfField['SubscriberPackageVersion']['BuildNumber'];\n } catch (\\Exception) {\n $version = null;\n }\n\n return $version;\n }\n\n /**\n * Store transcripts as note.\n *\n * @throws \\Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // For SF we also check if Log Notes is enabled.\n if ($this->profile->log_notes === Profile::LOG_NOTE_NONE) {\n return;\n }\n\n if ($activity->opportunity_id && $activity->prospect === null) {\n return;\n }\n\n try {\n $transcriptionData = $this->generateTranscription($activity);\n\n $noteMaxLength = $this->profile->log_notes === Profile::LOG_NOTE_ENHANCED\n ? self::ENHANCED_NOTE_MAX_LENGTH\n : self::CLASSIC_NOTE_MAX_LENGTH;\n\n $title = 'Transcript for ';\n $title .= $activity->title ?? $activity->activity_title;\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $body = mb_strimwidth($transcriptionData, 0, $noteMaxLength);\n\n if ($activity->opportunity_id) {\n $objectId = $activity->opportunity->crm_provider_id;\n } else {\n $objectId = $activity->prospect->crm_provider_id;\n }\n\n $noteId = $this->saveNote($title, $body, $objectId);\n\n // Store crm logged id in transcription.\n $transcription = $activity->getTranscription();\n $transcription->crm_activity_id = $noteId;\n $transcription->save();\n } catch (\\Exception $e) {\n \\Sentry::captureException($e);\n }\n }\n\n public function saveNote(string $title, string $body, string $objectId, ?NoteObject $noteObject = null): ?string\n {\n $noteId = null;\n\n try {\n if ($this->profile->log_notes === Profile::LOG_NOTE_ENHANCED) {\n $noteId = $this->buildEnhancedNote($title, $body, $objectId);\n } else {\n $noteId = $this->buildClassicNote($title, $body, $objectId);\n }\n } catch (HttpNotFoundException $exception) {\n // The profile not having access to create Enhanced Notes. Set their preference to Classic.\n if ($this->profile->log_notes === Profile::LOG_NOTE_ENHANCED) {\n $this->profile->update([\n 'log_notes' => Profile::LOG_NOTE_CLASSIC,\n ]);\n }\n }\n\n return $noteId;\n }\n\n /**\n * This is using the \"Enhanced\" Notes feature, NOT the \"Notes & Attachments\" feature being deprecated.\n *\n * @url https://salesforce.stackexchange.com/questions/104408/how-can-i-create-an-account-note-or-contact-note-via-api-that-is-visible-in-sale\n */\n private function buildEnhancedNote(string $title, string $body, string $objectId): string\n {\n // Decode stored entities, escape HTML (without quoting), then convert line breaks for Salesforce formatting\n $decodedBody = html_entity_decode($body, ENT_QUOTES | ENT_HTML5);\n $sanitizedBody = htmlspecialchars($decodedBody, ENT_NOQUOTES, 'UTF-8', false);\n $content = nl2br($sanitizedBody, false);\n $note = [\n 'OwnerId' => $this->profile->crm_provider_id,\n 'Title' => $title,\n 'Content' => base64_encode($content),\n ];\n\n $noteId = $this->createRecord('ContentNote', $note);\n\n $link = [\n 'ContentDocumentId' => $noteId,\n 'LinkedEntityId' => $objectId,\n 'ShareType' => 'I',\n ];\n\n $this->createRecord('ContentDocumentLink', $link);\n\n return $noteId;\n }\n\n private function buildClassicNote(string $title, string $body, string $objectId): string\n {\n if (in_array($this->parseObjectType($objectId), [Field::OBJECT_TASK, Field::OBJECT_EVENT])) {\n $this->logger->info('[Salesforce] Summary not sent', [\n 'profile_id' => $this->profile->id,\n 'objectId' => $objectId,\n 'reason' => 'Classical Note does not support Task/Event relation',\n ]);\n\n return '';\n }\n\n $titleTrimmed = null;\n\n if (mb_strlen($title) > 80) {\n $titleTrimmed = substr($title, 0, 77) . '...';\n }\n $payload = [\n 'OwnerId' => $this->profile->crm_provider_id,\n 'IsPrivate' => false,\n 'Title' => $titleTrimmed ?? $title,\n 'Body' => $titleTrimmed ? $title . PHP_EOL . $body : $body,\n 'ParentId' => $objectId,\n ];\n\n return $this->createRecord('Note', $payload);\n }\n\n /**\n * @inheritdoc\n */\n public function find(string $name, array $scopes): array\n {\n if ($this->profile === null) {\n return [];\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $limitValues = ['limit' => $this->limit, 'offset' => $this->offset];\n $sosl = $queryBuilder->buildFindQuery($name, $scopes, $limitValues);\n\n $this->logger->info('[Salesforce] Find prospects', [\n 'profile_id' => $this->profile->id,\n 'sosl_query' => $sosl,\n 'search_string' => $name,\n 'scopes' => $scopes,\n ]);\n\n $data = Cache::remember($this->profile->id . $sosl, self::CACHE_TTL, function () use ($sosl) {\n $data = [];\n\n try {\n // Hit remote API.\n $objects = $this->queryHandler->search($sosl);\n\n // Build mapped list.\n foreach ($objects as $object) {\n $type = strtolower($object['attributes']['type']);\n\n $record = [\n 'crmId' => $object['Id'],\n 'name' => $object['Name'],\n 'prospectType' => $type,\n 'phoneNumbers' => [],\n 'crmUrl' => $this->generateProviderUrl($object['Id'], $type),\n ];\n\n switch ($type) {\n case 'lead':\n if (empty($object['Company']) === false) {\n $record['organization'] = $object['Company'];\n }\n\n if (empty($object['Title']) === false) {\n $record['title'] = $object['Title'];\n }\n\n $stage = $this->config->stages()\n ->where('type', Stage::TYPE_LEAD)\n ->where('name', $object['Status'])\n ->first();\n\n // Lazy create the stage.\n if ($stage === null) {\n $stage = $this->importStages([Stage::TYPE_LEAD], $object['Status']);\n }\n\n if ($stage) {\n $record += [\n 'stage' => [\n 'id' => $stage->id_string,\n 'name' => $stage->name,\n ],\n ];\n }\n\n if (empty($object['RecordTypeId']) === false) {\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $object['RecordTypeId'])\n ->first();\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n }\n\n break;\n\n case 'account':\n if (empty($object['Industry']) === false) {\n $record['industry'] = $object['Industry'];\n $record['detailsLine'] = $object['Industry'];\n }\n if (! empty($object['PersonEmail'])) {\n $record['detailsLine'] = $object['PersonEmail'];\n }\n\n break;\n\n case 'contact':\n // For contacts, we should try and fetch their account name too.\n if ($object['AccountId']) {\n // Cheaper to get this locally.\n $account = $this->config->accounts()\n ->where('crm_provider_id', $object['AccountId'])\n ->first(['name']);\n\n if ($account) {\n $record['organization'] = $account->name;\n }\n }\n\n if (! empty($object['IsPersonAccount']) && $object['Email']) {\n $record['detailsLine'] = $object['Email'];\n } else {\n if (empty($object['Title']) === false) {\n $record['title'] = $object['Title'];\n }\n }\n\n break;\n }\n\n // Add phone numbers to record.\n if (empty($object['Phone']) === false && $object['Phone']) {\n $record['phoneNumbers'][] = [\n 'number' => $object['Phone'],\n 'nationalFormat' => phone_national($this->profile->user->country_code, $object['Phone']),\n 'type' => 'phone',\n ];\n }\n\n if (empty($object['MobilePhone']) === false && $object['MobilePhone']) {\n $record['phoneNumbers'][] = [\n 'number' => $object['MobilePhone'],\n 'nationalFormat' => phone_national(\n $this->profile->user->country_code,\n $object['MobilePhone']\n ),\n 'type' => 'mobile',\n ];\n }\n\n $data[] = $record;\n }\n } catch (NoResultsException $e) {\n $data = [];\n }\n\n return $data;\n });\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n\n if ($crmAccountId === null) {\n return $data;\n }\n\n if ($userId) {\n $profileRepository = app(ProfileRepository::class);\n $profile = $profileRepository->findProfileByUserId($this->config, $userId);\n\n $ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;\n }\n\n try {\n // Perhaps their profile has no opportunity permissions.\n if ($this->profile === null || $this->profile->opportunity_fields === null) {\n return $data;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $query = $queryBuilder->buildFindOpportunitiesQuery();\n\n $objects = $this->queryHandler->query($query, ['accountId' => $crmAccountId]);\n\n foreach ($objects as $object) {\n $record = [\n 'crmId' => $object['Id'],\n 'name' => $object['Name'],\n 'won' => $object['IsWon'],\n 'closed' => $object['IsClosed'],\n ];\n\n $valueFieldName = 'Amount';\n if ($this->config->opportunity_value_field_id) {\n $valueFieldName = $this->config->opportunityValueField->crm_provider_id;\n }\n\n if (empty($object[$valueFieldName]) === false) {\n $currency = $object['CurrencyIsoCode'] ?? $this->config->default_currency;\n $value = formatCurrency($object[$valueFieldName], $currency);\n\n $record += [\n 'value' => $value,\n ];\n }\n\n $stage = $this->config->stages()\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->where('name', $object['StageName'])\n ->first();\n\n // Lazy create the stage.\n if ($stage === null) {\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $object['StageName']);\n }\n\n $record += [\n 'stage' => [\n 'id' => $stage->id_string,\n 'name' => $stage->name,\n ],\n ];\n\n if (empty($object['RecordTypeId']) === false) {\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $object['RecordTypeId'])\n ->first();\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n }\n\n if ($ownerId && isset($object['OwnerId']) && $object['OwnerId'] === $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n } catch (NoResultsException $e) {\n return $data;\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n public function getContactRolesFromCrm(?Carbon $since = null): array\n {\n $roles = [];\n\n if ($this->profile === null) {\n return $roles;\n }\n\n $queryBuilder = app(QueryBuilder::class, ['profile' => $this->profile]);\n\n $query = $queryBuilder->buildGetContactRolesQuery($since);\n\n try {\n $objects = $this->queryHandler->query($query);\n\n foreach ($objects as $object) {\n $roles[] = [\n 'id' => $object['Id'],\n 'contactId' => $object['ContactId'],\n 'opportunityId' => $object['OpportunityId'],\n 'ownerId' => $object['Opportunity']['OwnerId'] ?? null,\n 'isPrimary' => $object['IsPrimary'],\n 'role' => $object['Role'],\n ];\n }\n } catch (NoResultsException) {\n // Just return an empty array.\n $this->logger->info('[Salesforce] No contact roles found', [\n 'since' => $since?->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n }\n\n return $roles;\n }\n\n public function syncContactRoles(Carbon $since): int\n {\n $contactRoleRepository = app(ContactRoleRepository::class);\n\n $crmContactRoles = $this->getContactRolesFromCrm(since: $since);\n $syncCount = 0;\n $contactRoles = [];\n\n foreach ($crmContactRoles as $crmContactRole) {\n $contactRoles[] = $this->importContactRole($crmContactRole);\n $syncCount++;\n }\n\n $contactRoleRepository->saveContactRoles($contactRoles);\n\n $this->syncRemotelyDeletedContactRoles();\n\n return $syncCount;\n }\n\n private function importContactRole(array $contactRole): array\n {\n $contact = $this->config->contacts()\n ->where('crm_provider_id', $contactRole['contactId'])\n ->first();\n\n if ($contact === null) {\n $contact = $this->syncContact($contactRole['contactId']);\n }\n\n $opportunity = $this->config->opportunities()\n ->where('crm_provider_id', $contactRole['opportunityId'])\n ->first();\n\n if ($opportunity === null) {\n $opportunity = $this->syncOpportunity($contactRole['opportunityId']);\n }\n\n $role = null;\n if (! empty($contactRole['role'])) {\n $role = mb_strimwidth($contactRole['role'], 0, 191);\n }\n\n return [\n 'crm_configuration_id' => $this->config->getId(),\n 'contact_id' => $contact->getId(),\n 'crm_provider_id' => $contactRole['id'],\n 'subject_type' => ContactRole::SUBJECT_TYPE_OPPORTUNITY,\n 'subject_id' => $opportunity->getId(),\n 'is_primary' => $contactRole['isPrimary'],\n 'role' => $role,\n ];\n }\n\n protected function syncRemotelyDeletedContactRoles(): bool\n {\n try {\n $deletedRemotely = $this->queryHandler->queryDeleted('OpportunityContactRole');\n } catch (NoResultsException $e) {\n return false;\n }\n\n $deletedOpportunities = $deletedRemotely->getResults();\n $deletedIds = array_column($deletedOpportunities, 'id');\n\n $contactRoleRepository = app(ContactRoleRepository::class);\n\n foreach (array_chunk($deletedIds, self::HARD_DELETE_CHUNK) as $chunk) {\n $contactRoleRepository->deleteContactRoles($chunk);\n\n $this->logger->info('[' . $this->getDisplayName() . '] Remotely deleted opportunities synced', [\n 'teamId' => $this->team->id_string,\n 'remotelyDeletedOpportunities' => $chunk,\n 'count' => count($chunk),\n ]);\n }\n\n return true;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = $objects = [];\n\n $hasWho = \\in_array($objectType, ['lead', 'contact']);\n $playbook = $this->getPlaybook($this->profile->user);\n $fields = array_merge(\n $this->profile->getFieldsAsArray(parent::OBJECT_TASK),\n $playbook && $playbook->activityField ? [$playbook->activityField->crm_provider_id] : []\n );\n\n // Query should default to any open call for that user.\n $query = '\n SELECT ' . implode(',', array_unique($fields)) . '\n FROM Task\n WHERE OwnerId = :ownerId\n AND IsArchived = false\n AND IsDeleted = false\n AND IsClosed = false\n AND (';\n\n if ($objectType === 'account') {\n // This covers tasks tied to a related contact or opportunity too.\n $query .= '\n AccountId = :accountId';\n }\n\n if ($hasWho) {\n $query .= '\n WhoId = :whoId';\n\n // If we are also going to check on a specific opportunity, set that up.\n if ($opportunityId) {\n $query .= ' OR WhatId = :whatId';\n }\n }\n\n $query .= ' ) ORDER BY LastModifiedDate DESC';\n\n try {\n $objects = $this->queryHandler->query($query, [\n 'ownerId' => $this->profile->crm_provider_id,\n 'whoId' => $objectId,\n 'whatId' => $opportunityId,\n 'accountId' => $objectId,\n ]);\n } catch (NoResultsException $e) {\n return $data;\n } finally {\n $this->logger->debug(sprintf('[Salesforce] Found %s tasks for query \"%s\"', count($objects), $query));\n }\n\n foreach ($objects as $object) {\n $dueDate = $object['ActivityDate'] ? Carbon::parse($object['ActivityDate'])->toIso8601String() : null;\n $data[] = [\n 'crmId' => $object['Id'],\n 'subject' => $object['Subject'],\n 'due' => $dueDate,\n 'type' => $object[$playbook->activityField->crm_provider_id],\n ];\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getEvents(string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = $objects = [];\n $user = $this->profile?->user;\n if ($this->profile === null || $user === null) {\n return $data;\n }\n\n $hasWho = \\in_array($objectType, ['lead', 'contact']);\n $playbook = $this->getPlaybook($user);\n $fields = array_merge(\n $this->profile->getFieldsAsArray(parent::OBJECT_EVENT),\n $playbook && $playbook->activityField ? [$playbook->activityField->crm_provider_id] : []\n );\n\n // Query should default to any event starting in the last week and ending up until today owned by the user.\n $query = '\n SELECT ' . implode(',', array_unique($fields)) . '\n FROM Event\n WHERE OwnerId = :ownerId\n AND IsArchived = false\n AND IsAllDayEvent = false\n AND StartDateTime >= LAST_N_DAYS:7\n AND EndDateTime <= TODAY\n AND (';\n\n if ($objectType === 'account') {\n // This covers events tied to a related contact or opportunity too.\n $query .= '\n AccountId = :accountId';\n }\n\n if ($hasWho) {\n $query .= '\n WhoId = :whoId';\n\n // If we are also going to check on a specific opportunity, set that up.\n if ($opportunityId) {\n $query .= ' OR WhatId = :whatId';\n }\n }\n\n $query .= ' ) ORDER BY LastModifiedDate DESC';\n\n try {\n $objects = $this->queryHandler->query($query, [\n 'ownerId' => $this->profile->crm_provider_id,\n 'whoId' => $objectId,\n 'whatId' => $opportunityId,\n 'accountId' => $objectId,\n ]);\n } catch (NoResultsException $e) {\n return $data;\n } finally {\n $this->logger->debug(sprintf('[Salesforce] Found %s tasks for query \"%s\"', count($objects), $query));\n }\n\n foreach ($objects as $object) {\n $dueDate = $object['StartDateTime'] ? Carbon::parse($object['StartDateTime'])->toIso8601String() : null;\n\n $data[] = [\n 'crmId' => $object['Id'],\n 'subject' => $object['Subject'],\n 'due' => $dueDate,\n 'type' => $object[$playbook->activityField->crm_provider_id],\n ];\n }\n\n return $data;\n }\n\n /**\n * Try to find CRM Objects using email address\n *\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchExactlyByEmail(string $email, ?int $userId = null): ?array\n {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $sosl = $queryBuilder->buildMatchByQuery($email, Field::TYPE_EMAIL);\n if ($sosl === null) {\n return null;\n }\n\n try {\n $objects = $this->queryHandler->search($sosl);\n $objects = $this->queryHandler->prioritiseResults(\n $objects,\n $email,\n QueryHandler::PRIORITISE_EMAIL\n );\n\n $data = $this->convertCrmData($objects, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (NoResultsException $e) {\n // Try the account next.\n if ($this->profile->account_fields === null) {\n return null;\n }\n }\n\n return null;\n }\n\n public function getDomain(string $email): ?string\n {\n // SF improved search - strip the domain extension, min domain name length 4\n return $this->getCompanyNameFromEmail(email: $email, minNameLength: 4);\n }\n\n /**\n * Try to find CRM objects using domain name of the email address\n *\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByDomain(string $domain, ?int $userId = null): ?array\n {\n $companyName = $domain;\n\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $sosl = $queryBuilder->buildMatchByDomainQuery($companyName);\n\n try {\n $objects = $this->queryHandler->search($sosl);\n\n $data = $this->convertCrmData($objects, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (NoResultsException) {\n return null;\n }\n }\n\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n // Don't bother looking up numbers that are masked.\n if (str_contains($phone, '**')) {\n return null;\n }\n\n if ($this->isPhoneNumberOfTeamMember($phone)) {\n return null;\n }\n\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $phoneNational = phone_national(null, $phone) ?? '';\n $possiblePhoneFormats = collect([\n preg_replace('/\\D/', '', ltrim($phone, '0+')),\n preg_replace('/\\D/', '', $phoneNational),\n formatDashPhoneNumber($phone),\n $phoneNational,\n ])\n ->filter() // Removes null and empty strings\n ->unique()\n ->values();\n\n foreach ($possiblePhoneFormats as $phone) {\n $sosl = $queryBuilder->buildMatchByQuery($phone, Field::TYPE_PHONE);\n if ($sosl === null) {\n continue;\n }\n\n try {\n $objects = $this->queryHandler->search($sosl);\n $objects = $this->queryHandler->prioritiseResults(\n $objects,\n $phone,\n QueryHandler::PRIORITISE_PHONE\n );\n\n return $this->convertCrmData($objects, $userId);\n } catch (NoResultsException) {\n continue;\n }\n }\n\n return null;\n }\n\n private function isPhoneNumberOfTeamMember(string $phone): bool\n {\n $teamRepository = app(TeamRepository::class);\n $user = $teamRepository->findTeamMemberByPhone($this->team, $phone);\n\n if ($user instanceof User) {\n return true;\n }\n\n return false;\n }\n\n protected function getCacheKey(string $object, ?int $userId = null): ?string\n {\n $key = $this->profile->id . $object;\n $keySuffix = $this->getOwnerKeySuffix($userId);\n\n return $key . $keySuffix;\n }\n\n private function getOwnerKeySuffix(?int $userId = null): string\n {\n return $userId === null ? '' : (string) $userId;\n }\n\n /** Determine the CRM Objects which represent the call activity. */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n if ($this->profile === null) {\n return null;\n }\n\n $cacheKey = $this->getCacheKey($name, $userId);\n\n $result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $sosl = $queryBuilder->buildMatchByQuery($name, 'name');\n if ($sosl === null) {\n return false;\n }\n\n try {\n $objects = $this->queryHandler->search($sosl);\n } catch (NoResultsException $e) {\n return false;\n }\n\n $objects = $this->queryHandler->prioritiseResults(\n $objects,\n $name,\n QueryHandler::PRIORITISE_NAME\n );\n\n $data = $this->convertCrmData($objects, $userId);\n\n return (! empty(array_filter($data))) ? $data : false;\n });\n\n return is_array($result) ? $result : null;\n }\n\n /**\n * @return array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n protected function convertCrmData(QueryIterator $objects, ?int $userId = null): array\n {\n $lead = null;\n $contact = null;\n $opportunity = null;\n $account = null;\n $stage = null;\n $countryCode = null;\n\n if ($objects->count() > 0) {\n $object = $objects->current();\n\n if ($object['attributes']['type'] === 'Lead') {\n $lead = $this->importLead($object);\n\n // Lead might not be imported if the Stage is null for example.\n if ($lead) {\n $countryCode = $lead->country_code;\n $stage = $lead->stage;\n }\n } else {\n if ($object['attributes']['type'] === 'Contact') {\n $contact = $this->importContact($object);\n $account = $contact->account;\n } else {\n $account = $this->importAccount($object);\n }\n\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account) {\n $countryCode = $account->country_code;\n }\n\n try {\n $sfOpportunities = $this->findOpportunities(\n $account?->getCrmProviderId(),\n $contact?->getCrmProviderId(),\n $userId\n );\n\n // Take the first opportunity, which will be ordered as priority based on their settings.\n if (! empty($sfOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($sfOpportunities[0]['crmId']);\n $stage = $opportunity?->stage;\n }\n } catch (Exception) {\n // Nothing to see here.\n }\n }\n }\n\n return [\n $lead,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n /**\n * @inheritdoc\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n if ($stage->type === Stage::TYPE_LEAD) {\n $objectType = 'Lead';\n $objectId = $crmObject->crm_provider_id;\n $objectStageType = 'Status';\n } else {\n $objectType = 'Opportunity';\n $objectId = $crmObject->crm_provider_id;\n $objectStageType = 'StageName';\n }\n\n $headers = [];\n if ($this->config->trigger_assignment_rules === false) {\n // @see: https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/headers_autoassign.htm\n $headers = [\n 'Sforce-Auto-Assign' => 'false',\n ];\n }\n\n $this->updateRecord($objectType, $objectId, [$objectStageType => $stage->name], $headers);\n }\n\n public function parseObjectType(string $objectId): string\n {\n if (Str::startsWith($objectId, '001')) {\n return 'account';\n }\n\n if (Str::startsWith($objectId, '003')) {\n return 'contact';\n }\n\n if (Str::startsWith($objectId, '00Q')) {\n return 'lead';\n }\n\n if (Str::startsWith($objectId, '006')) {\n return 'opportunity';\n }\n\n if (Str::startsWith($objectId, '00U')) {\n return 'event';\n }\n\n if (Str::startsWith($objectId, '00T')) {\n return 'task';\n }\n\n throw new \\InvalidArgumentException('Unsupported Object Type');\n }\n\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, ['profile' => $this->profile]);\n $query = $queryBuilder->buildGetUsersQuery($userToSearch);\n\n try {\n $salesforceUsers = $this->queryHandler->query($query, [\n 'active' => true,\n ]);\n } catch (NoResultsException $e) {\n $this->logger->info('[Salesforce] Sync Profiles. No users found', [\n 'query' => $query,\n 'error' => $e->getMessage(),\n ]);\n\n return null;\n }\n\n $teamRepository = app(TeamRepository::class);\n $customRules = $this->getCustomProfileRules($teamRepository);\n\n foreach ($salesforceUsers as $crmUser) {\n if ($crmUser['Email'] === null) {\n continue;\n }\n\n if (! $this->customProfileValidation($crmUser, $customRules)) {\n continue;\n }\n\n $user = $teamRepository->findActiveTeamMemberByEmail($this->team, $crmUser['Email']);\n\n if (! $user instanceof User) {\n continue;\n }\n\n $edition = $crmUser['UserPreferencesLightningExperiencePreferred']\n ? Profile::EDITION_LIGHTNING\n : Profile::EDITION_CLASSIC;\n\n $profileRepository = app(ProfileRepository::class);\n $profile = $profileRepository->updateOrCreateProfile(\n $user,\n [\n 'crm_configuration_id' => $this->config->getId(),\n 'crm_provider_id' => $crmUser['Id'],\n ],\n [\n 'user_id' => $user->getId(),\n 'edition' => $edition,\n 'has_external_cti' => ! empty($crmUser['CallCenterId']),\n 'crm_profile_id' => $crmUser['ProfileId'],\n ]\n );\n\n if ($userToSearch instanceof User && $userToSearch->getId() === $user->getId()) {\n return $profile;\n }\n }\n\n // Clean up inactive profiles\n try {\n $this->archiveInactiveProfiles();\n } catch (\\Exception $e) {\n $this->logger->warning('[Salesforce] Profile archiving failed', [\n 'teamId' => $this->team->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $url = null;\n\n // For Salesforce it's easy, we just point every object to the apex domain and they handle it.\n switch ($objectType) {\n case 'lead':\n case 'account':\n case 'contact':\n case 'opportunity':\n case 'task':\n case 'event':\n case 'activity':\n\n $url = $this->config->crm_base_url . '/' . $providerId;\n\n break;\n }\n\n return $url;\n }\n\n public function buildTaskSearchFields(): array\n {\n return ['Id', 'WhoId', 'WhatId', 'AccountId'];\n }\n\n public function getTaskByFilterConditions(\n array $fields,\n array $filters,\n bool $bulkSearch = false,\n bool $strictFilters = true\n ): ?array {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $query = $queryBuilder->buildSearchTaskQuery($fields, $filters, $bulkSearch, $strictFilters);\n\n try {\n if (! $bulkSearch) {\n $objects = $this->queryHandler->query($query, $filters);\n if ($objects->count() === 1) {\n return $objects->current();\n }\n }\n\n if ($bulkSearch) {\n $objects = $this->queryHandler->query($query);\n $records = [];\n foreach ($objects as $record) {\n $key = $record[end($fields)];\n $records[$key] = $record;\n }\n\n return $records;\n }\n } catch (\\Exception $e) {\n $this->logger->info('[Salesforce] Failed to execute query', [\n 'query' => $query,\n 'error' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function mapCrmObjects(array $task): array\n {\n $activityData = [];\n\n if (! empty($task['WhoId'])) {\n $type = $this->parseObjectType($task['WhoId']);\n $activityData[$type] = $task['WhoId'];\n }\n if (! empty($task['AccountId'])) {\n $activityData['account'] = $task['AccountId'];\n }\n if (! empty($task['WhatId'])) {\n $activityData['opportunity'] = $task['WhatId'];\n }\n\n return $activityData;\n }\n\n /**\n * Get SF task by Outreach call id.\n */\n public function getTaskByFilter(\n string $activityFieldType,\n array $filters,\n string $operator = '=',\n array $additionalFields = []\n ): ?array {\n $data = [];\n\n try {\n // Default (base) fields.\n $fields = ['Id', 'Subject', 'Description', 'ActivityDate', 'WhoId', 'WhatId', $activityFieldType];\n\n foreach ($additionalFields as $additionalField) {\n $fields[] = $additionalField->crm_provider_id;\n }\n\n $fields = array_unique($fields);\n\n // Find task with the same Outreach id as the call id.\n $query = 'SELECT ' . implode(',', $fields) . '\n FROM Task\n WHERE IsArchived = false AND IsDeleted = false';\n\n foreach ($filters as $key => $value) {\n $key = preg_quote($key, '/');\n $key = str_replace(['\\'', '\"'], '', $key);\n // Prepare the substitution.\n $strKey = \":$key\";\n\n $query .= \" AND $key $operator $strKey\";\n }\n\n $query .= ' ORDER BY LastModifiedDate DESC LIMIT 1';\n\n $objects = $this->queryHandler->query($query, $filters);\n\n // There should be only one task related to this call if any.\n if ($objects->count() === 1) {\n $object = $objects->current();\n\n $dueDate = $object['ActivityDate'] ? Carbon::parse($object['ActivityDate'])->toIso8601String() : null;\n\n $data = array_merge($object, [\n 'crmId' => $object['Id'],\n 'subject' => $object['Subject'],\n 'summary' => $object['Description'],\n 'due' => $dueDate,\n 'Type' => $object[$activityFieldType],\n ]);\n }\n } catch (NoResultsException $e) {\n // Filters don't match any records.\n } catch (ServiceUnavailableException $serviceUnavailableException) {\n // Service cannot be queried. We should probably log this.\n }\n\n return $data;\n }\n\n /**\n * Get Salesforce fields including datetime fields\n *\n * @param $objectType\n */\n private function getAllFieldsAsArray($objectType): array\n {\n $basicFields = [];\n // Not all users have access to all object fields.\n if ($this->profile->{$objectType . '_fields'}) {\n $basicFields = explode(',', $this->profile->{$objectType . '_fields'});\n }\n\n $extraFields = [\n 'CreatedDate',\n 'LastModifiedDate',\n 'IsDeleted',\n ];\n\n if ($objectType === self::OBJECT_OPPORTUNITY\n && $this->config->opportunity_value_field_id\n && ! in_array($this->config->opportunityValueField->crm_provider_id, $basicFields)\n ) {\n $extraFields[] = $this->config->opportunityValueField->crm_provider_id;\n }\n\n return array_unique(array_merge($basicFields, $extraFields));\n }\n\n /**\n * Generate transcription for activity description.\n */\n private function generateTranscription(Activity $activity): string\n {\n if (! ($this->config->store_transcript)) {\n // If sending transcription to activity toggle is disabled\n return '';\n }\n\n return $this->transcriptionService\n ->findTranscriptionByActivity($activity)\n ->map(static function (array $transcriptionSegment): string {\n return $transcriptionSegment['formattedStartsAt'] . ' | ' . $transcriptionSegment['transcript'];\n })\n ->implode(PHP_EOL);\n }\n\n /**\n * Find related Salesforce event based on activity data\n *\n * @return array<string>\n */\n public function fetchRelatedActivity(Activity $activity): array\n {\n $this->logger->info('[Salesforce] Searching for related activity', [\n 'activityId' => $activity->getUuid(),\n 'ownerId' => $this->profile?->crm_provider_id,\n ]);\n\n $sfEvent = $this->fetchRelatedEvent($activity);\n if (empty($sfEvent)) {\n $this->logger->info('[Salesforce] No related activity found', [\n 'activityId' => $activity->getUuid(),\n 'ownerId' => $this->profile?->crm_provider_id,\n 'account' => $activity->hasAccount()\n ? $activity->getAccount()->getCrmProviderId()\n : null,\n ]);\n\n return [];\n }\n\n return $sfEvent;\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n if ($activity->isTypeConference() === false) {\n return null;\n }\n\n if ($activity->hasActualStartTime() === false && $activity->hasScheduledStartTime() === false) {\n return null;\n }\n\n if (! $activity->hasProspect()) {\n $this->logger->info('[Salesforce] Skip look up, Activity not linked to Lead, Contact or Account', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n $playbook = $this->getPlaybook($activity->getUser());\n if ($playbook !== null && $playbook->getActivityType() === Playbook::ACTIVITY_TYPE_TASK) {\n $this->logger->info('[Salesforce] Skip auto-sync for task-based playbook', [\n 'activityUuid' => $activity->getUuid(),\n 'playbookId' => $playbook->getId(),\n 'playbookType' => $playbook->getActivityType(),\n ]);\n\n return null;\n }\n\n try {\n $sfEvent = $this->fetchRelatedActivity($activity);\n if (empty($sfEvent)) {\n return null;\n }\n\n [$activityField, $activityType] = $this->resolveActivityTypeFromEvent($activity, $sfEvent);\n\n $this->logger->info('[Salesforce] Found related activity', [\n 'activityId' => $activity->getUuid(),\n 'sfEvent' => $sfEvent['Id'],\n 'activityFieldName' => $activityField,\n 'crmActivityType' => ($activityField !== null && isset($sfEvent[$activityField]))\n ? $sfEvent[$activityField]\n : null,\n 'activityType' => $activityType,\n ]);\n\n $userId = $this->findRelatedActivityUserId($activity, $sfEvent);\n\n if ($activity->getUserId() !== $userId) {\n $this->logger->info('[Salesforce] Updating meeting owner', [\n 'activityId' => $activity->getUuid(),\n 'oldUserId' => $activity->getUserId(),\n 'newUserId' => $userId,\n ]);\n }\n\n $this->updateSfEventDescription($activity, $sfEvent);\n\n $activity->update([\n 'user_id' => $userId,\n 'crm_provider_id' => $sfEvent['Id'],\n 'playbook_category_id' => $activityType->id ?? $activity->getCategory()?->getId(),\n ]);\n\n $this->logger->info('[Salesforce] Activity updated', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return $activity;\n } catch (\\Exception $exception) {\n \\Sentry::captureException($exception);\n\n throw $exception;\n }\n }\n\n /**\n * @param array<string, mixed> $sfEvent\n *\n * @return array{0: string|null, 1: mixed}\n */\n private function resolveActivityTypeFromEvent(Activity $activity, array $sfEvent): array\n {\n $activityField = $this->getActivityFieldName($activity);\n $activityType = null;\n\n if ($activityField !== null && ! empty($sfEvent[$activityField])) {\n $playbook = $this->getPlaybook($activity->getUser());\n $activityType = $this->getPlaybookCategory($playbook, strval($sfEvent[$activityField]));\n }\n\n return [$activityField, $activityType];\n }\n\n /**\n * @param array<string> $sfEvent\n */\n private function findRelatedActivityUserId(Activity $activity, array $sfEvent): int\n {\n $userId = $activity->getUserId();\n\n if (empty($sfEvent['OwnerId']) === false) {\n $profile = $this\n ->config\n ->profiles()\n ->where('crm_provider_id', $sfEvent['OwnerId'])\n ->get()\n ->filter(static function (Profile $profile) use ($activity): bool {\n if (! $activity->isTypeConference()) {\n return ! empty($profile->user) ? $profile->user->isStatusActive() : false;\n }\n\n $participants = $activity->getParticipants();\n\n return ! empty($profile->user)\n ? $profile->user->isStatusActive()\n && $profile->user->hasPermission(PermissionEnum::RECORD_MEETING)\n && $participants->contains('user_id', $profile->user_id)\n : false;\n })\n ->first();\n\n if ($profile) {\n $userId = $profile->user_id;\n }\n }\n\n return $userId;\n }\n\n /**\n * @param array<string, mixed> $sfEvent\n */\n private function updateSfEventDescription(Activity $activity, array $sfEvent): void\n {\n try {\n if (str_contains($sfEvent['Description'], $activity->id_string)) {\n return;\n }\n\n $payload = [\n 'Description' => $sfEvent['Description']\n . PHP_EOL\n . PHP_EOL\n . (new DecorateActivity())->generateDescription($activity),\n ];\n\n $this->logger->info('[Salesforce] Update record', [\n 'activityId' => $activity->getUuid(),\n 'sfEvent' => $sfEvent['Id'],\n 'payload' => $payload,\n ]);\n\n $payload = array_merge(\n $payload,\n $this->payloadBuilder->fetchCustomFieldData($activity, Field::OBJECT_EVENT)\n );\n\n $this->updateRecord('Event', $sfEvent['Id'], $payload);\n } catch (\\Exception) {\n $this->logger->error('[Salesforce] Failed to update record', [\n 'activityUuid' => $activity->getUuid(),\n 'sfEvent' => $sfEvent['Id'],\n ]);\n }\n }\n\n /**\n * Returns the most recently modified Event within time range (if any).\n *\n * @return array|null An Event record from Salesforce.\n */\n private function fetchRelatedEvent(Activity $activity): ?array\n {\n $ownerId = $this->profile?->crm_provider_id;\n if ($ownerId === null) {\n return [];\n }\n\n /** @var ?Carbon $from */\n /** @var ?Carbon $to */\n [$from, $to] = $this->getFromToDates($activity);\n\n try {\n $whoId = null;\n $hasWho = $activity->lead_id || $activity->contact_id;\n if ($hasWho) {\n $whoId = $activity->hasLead()\n ? $activity->getLead()->crm_provider_id\n : $activity->getContact()->crm_provider_id;\n }\n\n if ($hasWho === false && $activity->account_id === null) {\n return null;\n }\n\n $query = $this->buildFetchRelatedEventQuery($activity);\n\n $objects = $this->queryHandler->query($query, [\n 'ownerId' => $ownerId,\n 'whoId' => $whoId,\n 'whatId' => $activity->hasOpportunity() ? $activity->getOpportunity()->crm_provider_id : null,\n 'accountId' => $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null,\n 'from' => $from?->format('Y-m-d\\TH:i:s\\Z'),\n 'to' => $to?->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($objects as $object) {\n return $object;\n }\n } catch (NoResultsException $e) {\n return [];\n }\n\n return [];\n }\n\n private function getFromToDates(Activity $activity): array\n {\n $from = null;\n $to = null;\n\n /** @var ?CalendarEvent $calendarEvent */\n $calendarEvent = $activity->calendarEvent()->first();\n if ($calendarEvent !== null) {\n $from = $calendarEvent->getStartTime();\n $to = $calendarEvent->getEndTime();\n }\n\n // For non-calendar imported activities\n // Also double check if calendar event dates could be null?\n // If null use what we've got so far\n if ($from === null || $to === null) {\n $from = $activity->hasScheduledStartTime()\n ? $activity->getScheduledStartTime()\n : $activity->getActualStartTime();\n $to = $activity->hasScheduledEndTime()\n ? $activity->getScheduledEndTime()->addMinutes(15)\n : $activity->getActualEndTime();\n }\n\n return [$from, $to];\n }\n\n /**\n * Determines the appropriate activity field name for querying Salesforce events.\n *\n * This method follows a hierarchy to determine the field name:\n * 1. Uses the playbook's activity field if it exists and is in the profile's accessible fields\n * 2. Falls back to the default activity field if the profile has no event fields configured\n * 3. Returns null if no suitable field is found\n *\n * @param Activity $activity The activity to determine the field for\n *\n * @return string|null The field name to use in queries, or null if none is available\n */\n private function getActivityFieldName(Activity $activity): ?string\n {\n if ($this->profile === null) {\n $this->logger->warning('[Salesforce] Cannot determine activity field - profile not found', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n $profileEventFields = $this->profile->getFieldsAsArray('event');\n\n if (empty($profileEventFields)) {\n $defaultActivityField = $this->getDefaultActivityField(Field::OBJECT_EVENT);\n $defaultFieldName = $defaultActivityField?->getAttribute('crm_provider_id');\n // Profile not yet synced — fall back to the default activity field.\n // There is a small chance that the profile won't have Default Activity Type field access\n // in which case the query will fail.\n // This is however an edge case and should be reviewed for profile sync issues.\n Sentry::withScope(function (\\Sentry\\State\\Scope $scope) use ($defaultFieldName): void {\n $scope->setContext('details', [\n 'profileId' => $this->profile->id,\n 'defaultField' => $defaultFieldName,\n ]);\n Sentry::captureMessage(\n '[Salesforce] Profile event fields empty, falling back to default activity field.',\n \\Sentry\\Severity::warning()\n );\n });\n\n return $defaultFieldName;\n }\n\n $playbook = $this->getPlaybook($activity->getUser());\n\n if (! is_null($playbook) && ! is_null($playbook->getActivityField())) {\n $playbookFieldName = $playbook->getActivityField()->getAttribute('crm_provider_id');\n\n if (in_array($playbookFieldName, $profileEventFields, true)) {\n return $playbookFieldName;\n }\n\n $this->logger->warning('[Salesforce] Playbook activity field not found in profile fields', [\n 'activityId' => $activity->getUuid(),\n 'playbookField' => $playbookFieldName,\n 'profileId' => $this->profile->id,\n ]);\n }\n\n return null;\n }\n\n private function buildFetchRelatedEventQuery(Activity $activity): string\n {\n $hasWho = $activity->lead_id || $activity->contact_id;\n\n $activityFieldName = $this->getActivityFieldName($activity);\n $fields = array_filter(['Id', 'Description', 'OwnerId', $activityFieldName]);\n\n $ownerCondition = '(OwnerId = :ownerId OR CreatedById = :ownerId)';\n\n $query = '\n SELECT ' . implode(',', $fields) . '\n FROM Event\n WHERE ' . $ownerCondition . '\n AND IsArchived = false\n AND IsAllDayEvent = false\n AND StartDateTime >= :from\n AND EndDateTime <= :to\n AND (';\n\n $operator = '';\n if ($activity->account_id) {\n // This covers events tied to a related contact or opportunity too.\n $query .= 'AccountId = :accountId';\n\n $operator = ' OR ';\n }\n\n if ($hasWho) {\n $query .= $operator . 'WhoId = :whoId';\n\n // If we are also going to check on a specific opportunity, set that up.\n if ($activity->opportunity_id) {\n $query .= ' OR WhatId = :whatId';\n }\n }\n\n $query .= ') ORDER BY LastModifiedDate DESC';\n\n return $query;\n }\n\n public function fetchProspect(array $task): array\n {\n $lead = $account = $opportunity = $contact = $stage = $countryCode = null;\n $externalId = $task['WhoId'] ?? null;\n\n // Lead or Contact\n if ($externalId) {\n try {\n [$lead, $account, $opportunity, $contact, $stage, $countryCode] = $this->parseRecords($externalId);\n } catch (\\InvalidArgumentException $exception) {\n // Invalid object type.\n }\n }\n\n // If we happen to know the opportunity or account from the Task, figure that out.\n if (empty($task['WhatId']) === false) {\n // WhatId could be either Account ID or Opportunity ID.\n // If WhatId is Opportunity ID, get the opportunity and stage from the CRM.\n try {\n [, $account, $opportunity, , $stage, ] = $this->parseRecords($task['WhatId']);\n } catch (\\InvalidArgumentException $exception) {\n // Invalid object type.\n }\n }\n\n return [$lead, $account, $opportunity, $contact, $stage, $countryCode];\n }\n\n /**\n * Save activity transcription summary as note\n */\n public function saveTranscriptionSummaryAsNote(\n ActivityContract $activity,\n string $title,\n string $body,\n ?string $objectId,\n ?NoteObject $noteObject = null,\n ): ?string {\n return $this->saveNote($title, $body, (string) $objectId);\n }\n\n public function getObjectByFilterConditions(string $objectType, array $fields, array $filters): ?array\n {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $query = $queryBuilder->buildObjectSearchQuery($objectType, $fields, $filters);\n\n try {\n $objects = $this->queryHandler->query($query, $filters);\n if ($objects->count() === 1) {\n return $objects->current();\n }\n } catch (\\Exception $e) {\n $this->logger->info('[Salesforce] Failed to execute query', [\n 'query' => $query,\n 'error' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n private function getCustomProfileRules(TeamRepository $teamRepository): array\n {\n $teamSettings = $teamRepository->getTeamSetting($this->team, 'custom_profile_validation');\n\n if ($teamSettings instanceof TeamSettings && $teamSettings->getValueType() === 'array') {\n $customRules = json_decode($teamSettings->getValue(), true);\n if (is_array($customRules)) {\n return $customRules;\n }\n }\n\n return [];\n }\n\n private function customProfileValidation(array $crmUser, array $customRules): bool\n {\n foreach ($customRules as $customRule) {\n if ($crmUser[$customRule['field']] !== $customRule['value']) {\n return false;\n }\n }\n\n return true;\n }\n\n /**\n * When syncing Contact / Lead / Account / Opportunity / Stage crm entities,\n * validate and restore locally trashed objects,\n * before updating them. Objects are identified by CrmProviderId\n */\n private function restoreAnyTrashedEntity(HasMany $targetEntity, string $crmProviderId): void\n {\n $recordExists = $targetEntity->withTrashed()->where(['crm_provider_id' => $crmProviderId])->first();\n if ($recordExists && $recordExists->trashed()) {\n $recordExists->restore();\n }\n }\n\n #[\\Override] public function supportsNotes(): bool\n {\n return true;\n }\n\n private function getOwnerProfile(?string $ownerId): ?Profile\n {\n if ($ownerId === null) {\n return null;\n }\n\n return $this->config->profiles()\n ->where('crm_provider_id', $ownerId)\n ->first();\n }\n}","depth":2,"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Salesforce;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse Illuminate\\Database\\Eloquent\\Relations\\HasMany;\nuse Illuminate\\Events\\Dispatcher;\nuse Illuminate\\Support\\Facades\\Cache;\nuse Illuminate\\Support\\Facades\\Log;\nuse Illuminate\\Support\\Str;\nuse Jiminny\\Component\\Country\\CountriesMap;\nuse Jiminny\\Contracts\\Acl\\PermissionEnum;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\FetchRelatedActivityInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\ImportsBusinessProcessesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\SalesforceBatchSyncInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\SalesforceInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteNoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SearchTaskInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SendSummaryToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SupportsObjectTypeParseInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmProfileRecordTypesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Enums\\CrmObject;\nuse Jiminny\\Events\\Activities\\Crm\\LeadConverted;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpBadRequestException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Exceptions\\NoResultsException;\nuse Jiminny\\Exceptions\\ServiceUnavailableException;\nuse Jiminny\\Jobs\\Crm\\NoteObject;\nuse Jiminny\\Jobs\\Crm\\MatchActivitiesToNewOpportunity;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Calendar\\CalendarEvent;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Contracts\\ActivityContract;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Configuration;\nuse Jiminny\\Models\\Crm\\ContactRole;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Crm\\RecordType;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\TeamSettings;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\ContactRoleRepository;\nuse Jiminny\\Repositories\\Crm\\FieldRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Repositories\\Crm\\RecordTypeFieldValuesRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\Helpers\\ArrayIterator;\nuse Jiminny\\Services\\Crm\\MatchDomainByEmailInterface;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Services\\Crm\\ResolveCompanyNameByEmailTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\Fields\\FieldHelper;\nuse Jiminny\\Services\\Crm\\Salesforce\\Fields\\FieldTypeConverter;\nuse Jiminny\\Services\\Crm\\Salesforce\\Fields\\ValueNormalizer;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\FollowupActivityTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\LogActivityTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\RecordManipulationsTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\SyncFieldsTrait;\nuse Jiminny\\Utils\\CurrencyFormatter;\nuse Jiminny\\Utils\\StringUtil;\nuse Ramsey\\Uuid\\Uuid;\nuse Sentry\\Laravel\\Facade as Sentry;\n\nclass Service extends BaseService implements\n SalesforceInterface,\n SalesforceBatchSyncInterface,\n SyncCrmEntitiesInterface,\n SyncCrmProfileRecordTypesInterface,\n ImportsBusinessProcessesInterface,\n RemoteEntityManipulationInterface,\n FetchRelatedActivityInterface,\n SendSummaryToCrmInterface,\n MatchDomainByEmailInterface,\n SearchTaskInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n SupportsObjectTypeParseInterface,\n RemoteNoteEntityManipulationInterface,\n VerifyTaskExistsInterface\n{\n use ResolveCompanyNameByEmailTrait;\n use SyncFieldsTrait;\n use DeleteObjectsTrait;\n use RecordManipulationsTrait;\n use ServiceTraits\\BatchSyncTrait;\n use FollowupActivityTrait;\n use LogActivityTrait;\n\n /**\n * Note Body Limit for the Old Note-Taking Tool\n *\n * @var int\n */\n private const int CLASSIC_NOTE_MAX_LENGTH = 32000;\n\n /**\n * Note Content Limit for the New Notes\n *\n * @var int\n */\n private const int ENHANCED_NOTE_MAX_LENGTH = 50000000;\n\n private const string INSTALLED_PACKAGE_ID = '033Tw0000007bKbIAI';\n\n private const int CACHE_TTL = 600;\n\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day - 86400\n\n /**\n * @var Client\n */\n protected $client;\n\n protected PayloadBuilder $payloadBuilder;\n protected QueryHandler $queryHandler;\n\n private OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n\n public function __construct(\n Client $client,\n PayloadBuilder $payloadBuilder,\n protected Dispatcher $eventDispatcher,\n private readonly CountriesMap $countriesMap,\n private readonly ProspectPhotoPathService $prospectPhotoPathService,\n ) {\n parent::__construct();\n\n $this->client = $client;\n $this->payloadBuilder = $payloadBuilder;\n $this->queryHandler = app(QueryHandler::class, [\n 'client' => $this->client,\n 'logger' => $this->logger,\n ]);\n $this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n }\n\n public function getDisplayName(): string\n {\n return 'Salesforce';\n }\n\n public function getJobDelay(): int\n {\n return 1;\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n return $user->getSocialAccount(SocialAccount::PROVIDER_SALESFORCE);\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($activity, $crmProviderId) {\n $playbook = $this->getPlaybookFromActivity($activity);\n\n if ($playbook === null) {\n $this->logger->warning('[Salesforce] Cannot verify task - no playbook found', [\n 'activity' => $activity->getId(),\n 'crm_provider_id' => $crmProviderId,\n ]);\n\n return false;\n }\n\n $objectType = $playbook->getActivityType() === Playbook::ACTIVITY_TYPE_EVENT ? 'Event' : 'Task';\n\n try {\n $record = $this->getRecord($objectType, $crmProviderId, ['Id', 'IsDeleted']);\n\n return ! empty($record) && ($record['IsDeleted'] ?? false) === false;\n } catch (HttpNotFoundException|HttpBadRequestException) {\n $this->logger->info('[Salesforce] Activity record not found during verification', [\n 'activity' => $activity->getId(),\n 'object_type' => $objectType,\n 'crm_provider_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n });\n }\n\n public function query(string $queryToRun, array $parameters = []): QueryIterator\n {\n // Due to poorly designed external calls, this method cannot be entirely removed\n return $this->queryHandler->query($queryToRun, $parameters);\n }\n\n /*=========== Organization Information ===============*/\n\n /**\n * Get a list of all the API Versions for the instance.\n *\n * @throws CrmException\n *\n * @return mixed\n *\n */\n public function getApiVersions()\n {\n $url = $this->config->crm_base_url . '/services/data';\n\n $response = $this->client->get($url);\n\n return json_decode($response->getBody(), true);\n }\n\n /**\n * Gets the valid recordTypes for a given Salesforce Object via the describe API.\n */\n private function getRecordTypes(string $crmObject): array\n {\n $url = $this->client->getObjectsUrl() . $crmObject . '/describe';\n\n $response = $this->client->get($url);\n $jsonResponse = json_decode($response->getBody(), true);\n\n $fields = [];\n foreach ($jsonResponse['recordTypeInfos'] as $row) {\n $fields[] = ['recordTypeId' => $row['recordTypeId'], 'default' => $row['defaultRecordTypeMapping']];\n }\n\n return $fields;\n }\n\n /**\n * Convert raw field data into a format compatible with CRM APIs.\n */\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return ValueNormalizer::normalize($fieldType, $fieldValue, $internal);\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n $defaultFields = ($activityType === Playbook::ACTIVITY_TYPE_TASK)\n ? FieldDefinitions::defaultTaskFields()\n : FieldDefinitions::defaultEventFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n // Setup the activity field as the default Type.\n /** @var Field $activityField */\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'Type',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK, Playbook::ACTIVITY_TYPE_EVENT];\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n\n $fieldFilter = ($activityType === Playbook::ACTIVITY_TYPE_TASK)\n ? FieldDefinitions::taskFollowupFieldsFilter()\n : FieldDefinitions::eventFollowupFieldsFilter();\n\n foreach ($fieldFilter as $eachFilter) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $eachFilter);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n /**\n * This one is now called only when ImportActivityTypes is triggered or SyncFieldMetadata executed manually\n * Regular sync now uses SharedSyncFieldsTrait -> syncSingleObjectType\n * Needs to be replaced later on\n */\n public function syncField(Field $field): void\n {\n try {\n if (FieldHelper::isCustomField($field)) {\n $query = '\n SELECT\n Id, Metadata, TableEnumOrId\n FROM\n CustomField\n WHERE\n DeveloperName = :fieldName\n AND\n TableEnumOrId = :fieldType\n AND\n NamespacePrefix = :namespacePrefix';\n\n // We need to constrain the field lookup to the object, in case it's used in multiple places.\n $objectType = \\in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true)\n ? 'activity'\n : $field->object_type;\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'fieldName' => substr($field->crm_provider_id, 0, -\\strlen('__c')),\n 'fieldType' => ucfirst($objectType),\n\n // This is used to ensure we only consider the field within the org, not installed packages.\n 'namespacePrefix' => 'null',\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n // Sync field metadata.\n $metadata = $sfField['Metadata'];\n\n $field->description = mb_strimwidth($metadata['description'] ?? '', 0, 191);\n $field->label = mb_strimwidth($metadata['label'] ?? '', 0, Field::LABEL_MAX_LENGTH);\n $field->type = $this->convertFieldType($metadata['type'], $field->getEntityName());\n $field->is_mandatory = ($metadata['required'] === true);\n $field->length = $metadata['length'];\n $field->default_value = mb_strimwidth(trim($metadata['defaultValue'] ?? '', '\"'), 0, 191);\n $field->save();\n } else {\n $query = '\n SELECT\n Id, DataType, DeveloperName, Label, Length, Description\n FROM\n FieldDefinition\n WHERE\n DurableId = :entityName';\n\n $entityName = $field->getEntityName();\n $sfFields = $this->queryHandler->metadata($query, [\n 'entityName' => $entityName,\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n $convertedType = $this->convertFieldType($sfField['DataType'], $entityName);\n $label = mb_strimwidth($sfField['Label'], 0, Field::LABEL_MAX_LENGTH);\n\n if ($field->isBusinessType()) {\n $label = 'Opportunity Type';\n }\n\n $field->description = mb_strimwidth($sfField['Description'], 0, Field::DESCRIPTION_MAX_LENGTH);\n $field->label = $label;\n $field->type = $convertedType;\n $field->length = $sfField['Length'];\n $field->save();\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n private function convertFieldType(string $from, ?string $entityName = null): string\n {\n $converter = new FieldTypeConverter();\n\n return $converter->convert($from, $entityName);\n }\n\n /**\n * @inheritdoc\n */\n public function importPicklistValues(Field $field): array\n {\n $values = [];\n $fieldValues = [];\n\n try {\n if (FieldHelper::isCustomField($field)) {\n $query = '\n SELECT\n Id, Metadata, TableEnumOrId\n FROM\n CustomField\n WHERE\n DeveloperName = :fieldName\n AND\n TableEnumOrId = :fieldType\n AND\n NamespacePrefix = :namespacePrefix';\n\n // We need to constrain the field lookup to the object, in case it's used in multiple places.\n $objectType = \\in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true) ?\n 'activity' : $field->object_type;\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'fieldName' => substr($field->crm_provider_id, 0, -\\strlen('__c')),\n 'fieldType' => ucfirst($objectType),\n // This is used to ensure we only consider the field within the org, not installed packages.\n 'namespacePrefix' => 'null',\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n $valueSet = $sfField['Metadata']['valueSet'];\n\n if ($valueSet['valueSetName'] === null) {\n // Local picklist values can be obtained easily.\n $picklistValues = $valueSet['valueSetDefinition']['value'];\n } else {\n // But for some fields, we just get the Global Value Picklist pointer so need to do more work.\n $picklistValues = $this->importGlobalValuePicklistValues($valueSet['valueSetName']);\n }\n\n // Import all active values.\n foreach ($picklistValues as $i => $sfFieldValue) {\n // Setup default value.\n if ($sfFieldValue['default']) {\n $field->update(['default_value' => $sfFieldValue['valueName']]);\n }\n\n // This comes through as null if active (lol).\n if ($sfFieldValue['isActive'] !== false) {\n $values[] = [\n 'value' => $sfFieldValue['valueName'],\n 'label' => $sfFieldValue['valueName'],\n 'sequence' => $i,\n 'is_default' => $sfFieldValue['default'],\n ];\n }\n }\n } else {\n $objectFields = $this->getObjectFields($field->object_type);\n $fieldId = $field->crm_provider_id;\n\n // Only work with our field of interest.\n $objectField = array_filter($objectFields, function ($item) use ($fieldId) {\n return $item['name'] === $fieldId;\n });\n\n $objectField = array_shift($objectField);\n if (empty($objectField['picklistValues']) === false) {\n foreach ($objectField['picklistValues'] as $i => $sfFieldValue) {\n // Skip inactive values.\n if ($sfFieldValue['active'] === false) {\n continue;\n }\n\n // Setup default value.\n if ($sfFieldValue['defaultValue']) {\n $field->update(['default_value' => $sfFieldValue['value']]);\n }\n\n $values[] = [\n 'value' => $sfFieldValue['value'],\n 'label' => $sfFieldValue['label'],\n 'sequence' => $i,\n 'is_default' => $sfFieldValue['defaultValue'],\n ];\n }\n }\n }\n\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n $value['value'] = substr($value['value'] ?? '', 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n if (($key = array_search($value['value'], $fieldsToPurge, true)) !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n // Get IDs of the values to be deleted\n $valuesToDelete = $field->values()->whereIn('value', $fieldsToPurge);\n $valuesToDeleteIds = $valuesToDelete->pluck('id');\n if (! $valuesToDeleteIds->isEmpty()) {\n $recordTypeFieldValuesRepository = app(RecordTypeFieldValuesRepository::class);\n $recordTypeFieldValuesRepository->deleteForCrmFieldValueIds($valuesToDeleteIds->toArray());\n\n // Now safely delete from crm_field_values\n $valuesToDelete->delete();\n }\n\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n return $fieldValues;\n }\n\n /**\n * Gets values from Global Value Picklists.\n */\n private function importGlobalValuePicklistValues(string $picklistName): array\n {\n $query = '\n SELECT\n Metadata\n FROM\n GlobalValueSet\n WHERE\n DeveloperName = :picklistName\n LIMIT 1';\n\n try {\n $sfValues = $this->queryHandler->metadata($query, [\n 'picklistName' => $picklistName,\n ]);\n\n // There is always 1 result at this point.\n $sfValue = $sfValues->current();\n\n return $sfValue['Metadata']['customValue'];\n } catch (NoResultsException $noResultsException) {\n // Nothing returned.\n\n return [];\n }\n }\n\n /**\n * @inheritdoc\n */\n public function syncProfileRecordTypes(): void\n {\n $objectTypes = [\n 'lead',\n 'account',\n 'contact',\n 'opportunity',\n 'task',\n 'event',\n ];\n\n foreach ($objectTypes as $objectType) {\n try {\n $crmRecordTypes = $this->getRecordTypes(ucfirst($objectType));\n\n foreach ($crmRecordTypes as $crmRecordType) {\n // If the record type is default and not the Master type, set this.\n if ($crmRecordType['default'] && $crmRecordType['recordTypeId'] !== '012000000000000AAA') {\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $crmRecordType['recordTypeId'])\n ->first();\n\n if ($recordType) {\n $this->profile->{$objectType . '_record_type_id'} = $recordType->id;\n }\n }\n }\n } catch (HttpNotFoundException $exception) {\n Log::error('No access to ' . $objectType . ' object, skipping...');\n\n // XXX: should we log this fact somewhere?\n continue;\n }\n }\n\n if ($this->profile->isDirty()) {\n $this->profile->save();\n }\n }\n\n /**\n * Gets business processes.\n */\n public function importBusinessProcesses(): void\n {\n $query = '\n SELECT\n Id, IsActive, Name, TableEnumOrId\n FROM\n BusinessProcess\n WHERE\n TableEnumOrId IN (\\'Lead\\',\\'Opportunity\\')';\n\n try {\n $sfProcesses = $this->queryHandler->query($query);\n\n // Upsert all processes for the team.\n foreach ($sfProcesses as $sfProcess) {\n /** @var BusinessProcess $businessProcess */\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $sfProcess['Id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => $sfProcess['Name'],\n 'type' => $sfProcess['TableEnumOrId'] === 'Lead' ? 'lead' : 'opportunity',\n 'is_selectable' => $sfProcess['IsActive'],\n ]);\n\n $this->importBusinessProcessStages($businessProcess);\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n /**\n * Gets business process stages.\n */\n private function importBusinessProcessStages(BusinessProcess $businessProcess): void\n {\n $query = '\n SELECT\n Metadata\n FROM\n BusinessProcess\n WHERE\n Id = :processId';\n\n try {\n $stages = [];\n $sfProcessStages = $this->queryHandler->metadata($query, [\n 'processId' => $businessProcess->crm_provider_id,\n ]);\n\n // There is always 1 result at this point.\n $sfProcessStage = $sfProcessStages->current();\n\n // Upsert all processes for the team.\n foreach ($sfProcessStage['Metadata']['values'] as $sfProcessStage) {\n $sanitizedName = urldecode($sfProcessStage['valueName']); // Must decode: \"%2C\" becomes \",\" etc.\n\n $stage = $businessProcess->crm->stages()\n // This MUST match on label because this API doesn't use API Name.\n ->where('label', $sanitizedName)\n ->where('type', $businessProcess->type)\n ->where('is_selectable', 1)\n ->first();\n\n if ($stage) {\n $stages[] = $stage->id;\n }\n }\n\n $businessProcess->stages()->sync($stages);\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n /**\n * Gets record types.\n */\n public function importRecordTypes(): void\n {\n $query = '\n SELECT\n Id, IsActive, Name, BusinessProcessId, SobjectType\n FROM\n RecordType';\n\n try {\n $sfRecordTypes = $this->queryHandler->query($query);\n\n // Upsert all record types for the process.\n foreach ($sfRecordTypes as $sfRecordType) {\n $businessProcess = null;\n if ($sfRecordType['BusinessProcessId']) {\n $businessProcess = $this->config->businessProcesses()\n ->where('crm_provider_id', $sfRecordType['BusinessProcessId'])\n ->first();\n }\n\n /** @var RecordType $recordType */\n $recordType = $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $sfRecordType['Id'],\n ], [\n 'team_id' => $this->team->id,\n 'type' => mb_strtolower($sfRecordType['SobjectType']),\n 'name' => $sfRecordType['Name'],\n 'is_selectable' => $sfRecordType['IsActive'],\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n $this->importRecordTypeFieldValues($recordType);\n }\n } catch (NoResultsException $noResultsException) {\n // Do nothing.\n }\n }\n\n /**\n * Import record type - field value mappings. This only works for standard fields.\n */\n private function importRecordTypeFieldValues(RecordType $recordType): void\n {\n try {\n $query = '\n SELECT\n Metadata\n FROM\n RecordType\n WHERE\n Id = :recordTypeId';\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'recordTypeId' => $recordType->crm_provider_id,\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n // Sync field metadata.\n $picklists = $sfField['Metadata']['picklistValues'];\n\n foreach ($picklists as $picklist) {\n $field = $this->config->fields()->where([\n 'type' => Field::TYPE_PICKLIST,\n 'object_type' => $recordType->type,\n 'crm_provider_id' => $picklist['picklist'],\n ])->first();\n\n if ($field) {\n $fieldValues = [];\n\n foreach ($picklist['values'] as $value) {\n // Must decode: \"%2C\" becomes \",\" etc.\n $fieldValue = $field->values()\n ->where('value', urldecode($value['valueName']))\n ->first();\n\n if ($fieldValue) {\n $fieldValues[] = $fieldValue->id;\n }\n }\n\n $recordType->fieldValues()->sync($fieldValues);\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n /**\n * @inheritdoc\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $params = [];\n $missingStage = null;\n if ($types === null) {\n $types = [Stage::TYPE_LEAD, Stage::TYPE_OPPORTUNITY];\n }\n\n foreach ($types as $type) {\n if ($type === Stage::TYPE_LEAD) {\n $query = '\n SELECT\n Id, ApiName, MasterLabel, SortOrder\n FROM\n LeadStatus';\n } else {\n $query = '\n SELECT\n Id, ApiName, MasterLabel, IsActive, SortOrder, DefaultProbability\n FROM\n OpportunityStage';\n }\n\n if ($missingStageName) {\n $escapedStageName = ValueNormalizer::replaceQueryWithStringLiterals($missingStageName);\n\n $query .= ' WHERE ApiName = :stageName';\n\n $params = [\n 'stageName' => $escapedStageName,\n ];\n }\n\n try {\n $sfStages = $this->queryHandler->query($query, $params);\n } catch (NoResultsException $exception) {\n $sfStages = [];\n }\n\n $missingStage = null;\n\n // Upsert all stages for the team.\n foreach ($sfStages as $sfStage) {\n $selectable = true;\n if (array_key_exists('IsActive', $sfStage)) {\n $selectable = $sfStage['IsActive'];\n }\n\n $this->restoreAnyTrashedEntity($this->config->stages(), $sfStage['Id']);\n\n $stage = $this->config->stages()->updateOrCreate([\n 'crm_provider_id' => $sfStage['Id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($sfStage['ApiName'], 0, 50),\n 'label' => mb_strimwidth($sfStage['MasterLabel'], 0, 191),\n 'type' => $type,\n 'sequence' => $sfStage['SortOrder'] ?? 0,\n 'is_selectable' => $selectable,\n 'probability' => $sfStage['DefaultProbability'] ?? null,\n ]);\n\n if ($missingStageName && $missingStageName === $sfStage['ApiName']) {\n $missingStage = $stage;\n }\n }\n\n if ($missingStageName && $missingStage === null) {\n // If they requested a stage that still doesn't exist, it must be inactive so lazy create it.\n $missingStage = $this->config->stages()->create([\n 'crm_provider_id' => Uuid::uuid4(),\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($missingStageName, 0, 50),\n 'label' => mb_strimwidth($missingStageName, 0, 191),\n 'type' => $type,\n 'sequence' => 0,\n 'is_selectable' => 0,\n ]);\n }\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int\n {\n $syncCount = 0;\n $fields = $this->getAllFieldsAsArray('lead');\n if (\\in_array('Id', $fields, true) === false) {\n return $syncCount;\n }\n\n $query = '\n SELECT ' . rtrim(implode(',', $fields), ',') . '\n FROM Lead\n WHERE LastModifiedDate > :since\n ORDER BY LastModifiedDate ASC';\n\n try {\n $sfLeads = $this->queryHandler->query($query, [\n 'since' => $since->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($sfLeads as $sfLead) {\n // Only sync if previously imported.\n if ($this->hasLead($sfLead['Id'])) {\n $this->importLead($sfLead);\n $syncCount++;\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::LEAD);\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLead(string $crmId): ?Lead\n {\n $fields = $this->getAllFieldsAsArray('lead');\n\n $sfLead = $this->getRecord('Lead', $crmId, $fields);\n\n return $this->importLead($sfLead);\n }\n\n private function importLead($crmData): ?Lead\n {\n /** @var ?Stage $stage */\n $stage = null;\n if (isset($crmData['Status'])) {\n // Get the current stage.\n $stage = $this->config\n ->stages()\n ->where('name', $crmData['Status'])\n ->where('type', Stage::TYPE_LEAD)\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_LEAD], $crmData['Status']);\n }\n }\n\n // If we have no way of importing this, just return null :(\n if ($stage === null) {\n return null;\n }\n\n $countryCode = $crmData['CountryCode'] ?? null;\n\n // Salesforce allows custom \"countries\" to be created. Disregard these.\n if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {\n $countryCode = null;\n }\n\n // If we have no country code, try to parse it from the country name.\n if ($countryCode === null && empty($crmData['Country']) !== false) {\n $countryCode = $this->convertCountryNameToCode($crmData['Country']);\n }\n\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($crmData['Phone'] ?? '', 0, 25);\n $parsedNumber = parsePhoneNumber($countryCode, $number);\n\n $mobilePhone = null;\n if (empty($crmData['MobilePhone']) === false) {\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($crmData['MobilePhone'], 0, 25);\n $mobilePhone = phone_e164($countryCode, $number);\n }\n\n $convertedDate = null;\n $convertedAccount = null;\n $convertedOpportunity = null;\n $convertedContact = null;\n\n if ($crmData['IsConverted'] == 'true') {\n $convertedDate = $crmData['ConvertedDate'];\n\n if (empty($crmData['ConvertedAccountId']) === false) {\n $convertedAccount = $this->config\n ->accounts()\n ->where('crm_provider_id', $crmData['ConvertedAccountId'])\n ->first();\n\n if ($convertedAccount === null) {\n try {\n $convertedAccount = $this->syncAccount($crmData['ConvertedAccountId']);\n } catch (HttpNotFoundException $exception) {\n // Probably the user has no permissions to access the converted data.\n }\n }\n }\n\n if (empty($crmData['ConvertedOpportunityId']) === false) {\n $convertedOpportunity = $this->config\n ->opportunities()\n ->where('crm_provider_id', $crmData['ConvertedOpportunityId'])\n ->first();\n\n if ($convertedOpportunity === null) {\n try {\n $convertedOpportunity = $this->syncOpportunity($crmData['ConvertedOpportunityId']);\n } catch (HttpNotFoundException $exception) {\n // Probably the user has no permissions to access the converted data.\n }\n }\n }\n\n if (empty($crmData['ConvertedContactId']) === false) {\n $convertedContact = $this->team\n ->crm\n ->contacts()\n ->where('crm_provider_id', $crmData['ConvertedContactId'])\n ->first();\n\n if ($convertedContact === null) {\n try {\n $convertedContact = $this->syncContact($crmData['ConvertedContactId']);\n } catch (HttpNotFoundException $exception) {\n // Probably the user has no permissions to access the converted data.\n }\n }\n }\n }\n\n if (empty($crmData['Company'])) {\n $company = 'Unknown';\n } else {\n $company = mb_strimwidth($crmData['Company'], 0, 191);\n }\n\n $domain = null;\n if (empty($crmData['Website']) === false) {\n $domain = mb_strimwidth($crmData['Website'], 0, 191);\n $domain = StringUtil::resolveDomain($domain);\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $data = [\n 'team_id' => $this->team->id,\n 'user_id' => $profile?->user_id,\n 'owner_id' => $crmData['OwnerId'] ?? '',\n 'company' => $company,\n 'domain' => $domain,\n 'name' => $crmData['Name'] ? mb_strimwidth($crmData['Name'], 0, 191) : '',\n 'title' => $crmData['Title'] ? mb_strimwidth($crmData['Title'], 0, 128) : null,\n 'email' => $crmData['Email'] ? mb_strimwidth($crmData['Email'], 0, 80) : null,\n 'phone' => $parsedNumber['phone'],\n 'ext' => $parsedNumber['ext'] ?? null,\n 'mobile_phone' => $mobilePhone,\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['Id'],\n modelType: Lead::class,\n fileName: $crmData['Id'],\n avatarText: $crmData['Name']\n ),\n 'stage_id' => $stage->id,\n 'record_type_id' => null,\n 'converted_at' => $convertedDate,\n 'converted_account_id' => $convertedAccount->id ?? null,\n 'converted_opportunity_id' => $convertedOpportunity->id ?? null,\n 'converted_contact_id' => $convertedContact->id ?? null,\n 'country_code' => $countryCode,\n 'remotely_created_at' => $createdDate,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->leads(), $crmData['Id']);\n\n /** @var Lead $lead */\n $lead = $this->config->leads()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n if ($lead->wasChanged('converted_at') && $lead->getConvertedAt() !== null) {\n $this->eventDispatcher->dispatch(new LeadConverted($lead));\n }\n\n $this->handleObjectDeletion($lead, $crmData);\n\n return $lead;\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccounts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n $fields = $this->getAllFieldsAsArray('account');\n\n if (\\in_array('Id', $fields, true) === false) {\n return $syncCount;\n }\n\n $query = '\n SELECT ' . rtrim(implode(',', $fields), ',') . '\n FROM Account\n WHERE LastModifiedDate > :since\n ORDER BY LastModifiedDate ASC';\n\n try {\n $sfAccounts = $this->queryHandler->query($query, [\n 'since' => $since->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($sfAccounts as $sfAccount) {\n // Only sync if previously imported.\n if ($this->hasAccount($sfAccount['Id'])) {\n $this->importAccount($sfAccount);\n $syncCount++;\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::ACCOUNT);\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccount(string $crmId): ?Account\n {\n $fields = $this->getAllFieldsAsArray('account');\n if (! in_array('Id', $fields, true)) {\n $this->logger->info('[Salesforce] Sync account cancelled. Fields are not available.', [\n 'crmId' => $crmId,\n 'userId' => $this->profile->getUserId(),\n ]);\n\n return null;\n }\n\n $sfAccount = $this->getRecord('Account', $crmId, $fields);\n\n return $this->importAccount($sfAccount);\n }\n\n private function importAccount($crmData): Account\n {\n $countryCode = $crmData['BillingCountryCode'] ?? $crmData['ShippingCountryCode'] ?? null;\n\n // Salesforce allows custom \"countries\" to be created. Disregard these.\n if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {\n $countryCode = null;\n }\n\n // If we have no country code, try to parse it from the country names.\n if ($countryCode === null && empty($crmData['BillingCountry']) === false) {\n $countryCode = $this->convertCountryNameToCode($crmData['BillingCountry']);\n }\n\n if ($countryCode === null && empty($crmData['ShippingCountry']) === false) {\n $countryCode = $this->convertCountryNameToCode($crmData['ShippingCountry']);\n }\n\n if (empty($crmData['Phone']) === false) {\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($crmData['Phone'], 0, 25);\n $parsedNumber = parsePhoneNumber($countryCode, $number);\n } else {\n $parsedNumber = [];\n }\n\n $industry = null;\n if (empty($crmData['Industry']) === false) {\n $industry = mb_strimwidth($crmData['Industry'], 0, 40);\n }\n\n $domain = null;\n if (empty($crmData['Website']) === false) {\n $domain = mb_strimwidth($crmData['Website'], 0, 191);\n $domain = StringUtil::resolveDomain($domain);\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $data = [\n 'team_id' => $this->team->id,\n 'user_id' => $profile?->user_id,\n 'owner_id' => $crmData['OwnerId'],\n 'name' => mb_strimwidth($crmData['Name'], 0, 191),\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['Id'],\n modelType: Account::class,\n fileName: $crmData['Id'],\n avatarText: $crmData['Name']\n ),\n 'industry' => $industry,\n 'domain' => $domain,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'ext' => $parsedNumber['ext'] ?? null,\n 'country_code' => $countryCode,\n 'remotely_created_at' => $createdDate,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->accounts(), $crmData['Id']);\n\n /** @var Account $account */\n $account = $this->config->accounts()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n $this->handleObjectDeletion($account, $crmData);\n\n return $account;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOpportunities(array $parameters, ?string $strategy = null): int\n {\n $strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);\n\n $syncCount = 0;\n $logParams = $parameters;\n $parameters['profile'] = $this->profile;\n $logParams['user'] = $this->profile->getUserId();\n\n if (count($strategies) > 1) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Multiple sync strategies used', [\n 'teamId' => $this->team->getUuid(),\n 'params' => $logParams,\n 'strategies_count' => count($strategies),\n ]);\n }\n\n foreach ($strategies as $syncStrategy) {\n $name = $syncStrategy->getStrategyName();\n\n try {\n $sfOpportunities = $syncStrategy->fetchOpportunities($parameters);\n $totalRecords = $sfOpportunities->count();\n\n foreach ($sfOpportunities as $sfOpportunity) {\n $this->importOpportunity($sfOpportunity);\n $syncCount++;\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n $this->logger->warning('[' . $this->getDisplayName() . '] No opportunities found', [\n 'teamId' => $this->team->getUuid(),\n 'name' => $name,\n 'params' => $logParams,\n 'reason' => $noResultsException->getMessage(),\n ]);\n } catch (CrmException $crmException) {\n // Nothing to sync.\n $this->logger->warning('[' . $this->getDisplayName() . '] Opportunity sync failed', [\n 'teamId' => $this->team->getUuid(),\n 'name' => $name,\n 'params' => $logParams,\n 'reason' => $crmException->getMessage(),\n ]);\n }\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::OPPORTUNITY, ['params' => $logParams]);\n\n // debug to see how if count of opportunities reaches 1000\n if ($syncCount >= 1000) {\n $this->logger->info(\n '[' . $this->getDisplayName() . '] Sync Opportunities - count warning',\n [\n 'team_id' => $this->team->getId(),\n 'params' => $logParams,\n 'count' => $syncCount,\n 'strategies_count' => count($strategies),\n 'total_records' => $totalRecords ?? null,\n ]\n );\n }\n\n return $syncCount;\n }\n\n\n /**\n * @inheritdoc\n */\n public function syncOpportunity(string $crmId): ?Opportunity\n {\n $strategy = $this->opportunitySyncStrategyResolver->resolve(\n $this->config,\n OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY\n );\n\n $parameters = [\n 'profile' => $this->profile,\n 'crm_id' => $crmId,\n ];\n\n try {\n $sfOpportunity = $strategy->fetchOpportunities($parameters);\n } catch (HttpNotFoundException $e) {\n $this->logger->info('[' . $this->getDisplayName() . '] Opportunity not found', [\n 'teamId' => $this->team->id_string,\n 'crmId' => $crmId,\n ]);\n\n return null;\n } catch (CrmException $crmException) {\n $this->logger->info('[' . $this->getDisplayName() . '] Opportunity sync failed', [\n 'teamId' => $this->team->id_string,\n 'crmId' => $crmId,\n 'exception' => $crmException->getMessage(),\n ]);\n\n return null;\n }\n\n if ($sfOpportunity instanceof ArrayIterator) {\n return $this->importOpportunity($sfOpportunity->getItems());\n }\n\n return $this->importOpportunity($sfOpportunity);\n }\n\n /**\n * @throws HttpNotFoundException\n */\n private function importOpportunity($crmData): ?Opportunity\n {\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $account = null;\n if (empty($crmData['AccountId']) === false) {\n /** @var ?Account $account */\n $account = $this->config->accounts()\n ->where('crm_provider_id', (string) $crmData['AccountId'])\n ->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmData['AccountId']);\n }\n }\n\n $userId = $profile?->getUserId() ?? $account?->getUserId();\n if ($userId === null) {\n $this->logger->error('[Salesforce] | Skip import, no user_id found', [\n 'id' => $crmData['Id'],\n ]);\n\n return null;\n }\n\n /** @var ?Stage $stage */\n $stage = null;\n if (isset($crmData['StageName'])) {\n $stage = $this->config\n ->stages()\n ->where('name', $crmData['StageName'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->orderBy('is_selectable', 'DESC')\n ->orderBy('id')\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $crmData['StageName']);\n }\n }\n\n $recordType = null;\n if (empty($crmData['RecordTypeId']) === false) {\n /** @var ?RecordType $recordType */\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $crmData['RecordTypeId'])\n ->first();\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $closeDate = null;\n if (empty($crmData['CloseDate']) === false) {\n $closeDate = Carbon::parse($crmData['CloseDate'])->format('Y-m-d');\n }\n\n $valueFieldName = 'Amount';\n if ($this->config->opportunity_value_field_id) {\n $valueFieldName = $this->config->opportunityValueField->crm_provider_id;\n }\n\n $data = [\n 'team_id' => $this->team->id,\n 'account_id' => $account->id ?? null,\n 'user_id' => $userId,\n 'owner_id' => $crmData['OwnerId'] ?? null,\n 'name' => mb_strimwidth($crmData['Name'] ?? '', 0, 128),\n 'value' => $crmData[$valueFieldName],\n 'currency_code' => CurrencyFormatter::formatCode($crmData['CurrencyIsoCode'] ?? null),\n 'close_date' => $closeDate,\n 'is_closed' => $crmData['IsClosed'],\n 'is_won' => $crmData['IsWon'],\n 'stage_id' => $stage?->id ?? null,\n 'record_type_id' => $recordType->id ?? null,\n 'remotely_created_at' => $createdDate,\n 'probability' => $crmData['Probability'] ?? null,\n 'forecast_category' => $crmData['ForecastCategoryName'] ?? null,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->opportunities(), $crmData['Id']);\n\n // Do not allow locked DB tables & other errors\n // to interrupt the process of reverting the trashed opportunities\n try {\n /** @var Opportunity $opportunity */\n $opportunity = $this->config->opportunities()\n ->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n // import external fields into crm_field_data if present\n $crmFields = $this->getOpportunitySyncableFields();\n\n $this->importOpportunityCrmFieldData($crmData, $crmFields, $opportunity->id);\n\n $this->handleObjectDeletion($opportunity, $crmData);\n\n if ($opportunity->wasRecentlyCreated) {\n MatchActivitiesToNewOpportunity::dispatch($opportunity->getId());\n }\n\n return $opportunity;\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n $this->logger->error('[Salesforce] importOpportunity failure.', [\n 'crm_provider_id' => $crmData['Id'],\n 'team_id' => $this->team->id,\n 'exception' => $exception->getMessage(),\n ]);\n\n $this->handleEntityDeletionByProviderId($this->config->opportunities(), $crmData);\n }\n\n return null;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContacts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n $fields = $this->getAllFieldsAsArray('contact');\n if (\\in_array('Id', $fields, true) === false) {\n return $syncCount;\n }\n\n $query = '\n SELECT ' . rtrim(implode(',', $fields), ',') . '\n FROM Contact\n WHERE LastModifiedDate > :since\n ORDER BY LastModifiedDate ASC';\n\n try {\n $sfContacts = $this->queryHandler->query($query, [\n 'since' => $since->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($sfContacts as $sfContact) {\n // Only sync if previously imported.\n if ($this->hasContact($sfContact['Id'])) {\n $this->importContact($sfContact);\n $syncCount++;\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::CONTACT);\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContact(string $crmId): ?Contact\n {\n $fields = $this->getAllFieldsAsArray('contact');\n if (! in_array('Id', $fields, true)) {\n $this->logger->info('[Salesforce] Sync contact cancelled. Fields are not available.', [\n 'crmId' => $crmId,\n 'userId' => $this->profile->getUserId(),\n ]);\n\n return null;\n }\n\n $sfContact = $this->getRecord('Contact', $crmId, $fields);\n\n return $this->importContact($sfContact);\n }\n\n private function importContact($crmData): Contact\n {\n $account = null;\n // Contacts may not have accounts...\n if (isset($crmData['AccountId'])) {\n $account = $this->config->accounts()\n ->where('crm_provider_id', (string) $crmData['AccountId'])\n ->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmData['AccountId']);\n }\n }\n\n $countryCode = $crmData['MailingCountryCode'] ?? null;\n\n // Salesforce allows custom \"countries\" to be created. Disregard these.\n if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {\n $countryCode = null;\n }\n\n // If we have no country code, try to parse it from the country name.\n if ($countryCode === null && empty($crmData['MailingCountry']) === false) {\n $countryCode = $this->convertCountryNameToCode($crmData['MailingCountry']);\n\n if ($countryCode === null && $account) {\n $countryCode = $account->country_code;\n }\n }\n\n $ext = null;\n $parsedNumber = [];\n if (empty($crmData['Phone']) === false) {\n $number = Str::limit($crmData['Phone'], 25, '');\n $parsedNumber = parsePhoneNumber($countryCode, $number);\n\n if (empty($parsedNumber['ext']) === false) {\n $ext = Str::limit($parsedNumber['ext'], 10, '');\n }\n }\n\n $mobileNumber = null;\n if (empty($crmData['MobilePhone']) === false) {\n $mobileNumber = Str::limit(phone_e164($countryCode, $crmData['MobilePhone']), 25, '');\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $data = [\n 'team_id' => $this->team->id,\n 'account_id' => $account->id ?? null,\n 'user_id' => $profile?->user_id,\n 'owner_id' => $crmData['OwnerId'] ?? null,\n 'name' => ($crmData['Name'] ?? null) !== null ? mb_strimwidth($crmData['Name'], 0, 100) : '',\n 'title' => ($crmData['Title'] ?? null) !== null ? mb_strimwidth($crmData['Title'], 0, 128) : null,\n 'email' => ($crmData['Email'] ?? null) !== null ? mb_strimwidth($crmData['Email'], 0, 191) : null,\n 'country_code' => $countryCode,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'ext' => $ext,\n 'mobile_phone' => $mobileNumber,\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['Id'],\n modelType: Contact::class,\n fileName: $crmData['Id'],\n avatarText: $crmData['Name']\n ),\n 'remotely_created_at' => $createdDate,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->contacts(), $crmData['Id']);\n\n /** @var Contact $contact */\n $contact = $this->config->contacts()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n $this->handleObjectDeletion($contact, $crmData);\n\n return $contact;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n $fields = [\n 'InstanceName',\n 'OrganizationType',\n 'IsSandbox',\n ];\n\n $orgValues = $this->getRecord('Organization', $this->config->crm_provider_id, $fields);\n\n $edition = null;\n switch ($orgValues['OrganizationType']) {\n case 'Developer Edition':\n $edition = Configuration::EDITION_DEVELOPER;\n\n break;\n\n case 'Professional Edition':\n $edition = Configuration::EDITION_PROFESSIONAL;\n\n break;\n\n case 'Enterprise Edition':\n $edition = Configuration::EDITION_ENTERPRISE;\n\n break;\n }\n\n $this->config->edition = $edition;\n $this->config->instance = $orgValues['InstanceName'];\n\n // XXX: How can this state be possible?\n if ($this->config->version === null) {\n $this->config->version = Client::MIN_API_VERSION;\n }\n\n $installedVersion = $this->getInstalledAppVersion();\n if ($installedVersion !== null) {\n $installedVersion = (string) $this->getInstalledAppVersion();\n }\n\n $this->config->installed_app_version = $installedVersion;\n\n $this->config->save();\n }\n\n public function getInstalledAppVersion(): ?string\n {\n try {\n $query = '\n SELECT\n SubscriberPackageVersion.MajorVersion,\n SubscriberPackageVersion.MinorVersion,\n SubscriberPackageVersion.PatchVersion,\n SubscriberPackageVersion.BuildNumber\n FROM\n InstalledSubscriberPackage\n WHERE\n SubscriberPackageId = :packageId\n ';\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'packageId' => self::INSTALLED_PACKAGE_ID,\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n // Grab version number.\n $version = $sfField['SubscriberPackageVersion']['MajorVersion'] .\n $sfField['SubscriberPackageVersion']['MinorVersion'] .\n $sfField['SubscriberPackageVersion']['PatchVersion'] .\n $sfField['SubscriberPackageVersion']['BuildNumber'];\n } catch (\\Exception) {\n $version = null;\n }\n\n return $version;\n }\n\n /**\n * Store transcripts as note.\n *\n * @throws \\Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // For SF we also check if Log Notes is enabled.\n if ($this->profile->log_notes === Profile::LOG_NOTE_NONE) {\n return;\n }\n\n if ($activity->opportunity_id && $activity->prospect === null) {\n return;\n }\n\n try {\n $transcriptionData = $this->generateTranscription($activity);\n\n $noteMaxLength = $this->profile->log_notes === Profile::LOG_NOTE_ENHANCED\n ? self::ENHANCED_NOTE_MAX_LENGTH\n : self::CLASSIC_NOTE_MAX_LENGTH;\n\n $title = 'Transcript for ';\n $title .= $activity->title ?? $activity->activity_title;\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $body = mb_strimwidth($transcriptionData, 0, $noteMaxLength);\n\n if ($activity->opportunity_id) {\n $objectId = $activity->opportunity->crm_provider_id;\n } else {\n $objectId = $activity->prospect->crm_provider_id;\n }\n\n $noteId = $this->saveNote($title, $body, $objectId);\n\n // Store crm logged id in transcription.\n $transcription = $activity->getTranscription();\n $transcription->crm_activity_id = $noteId;\n $transcription->save();\n } catch (\\Exception $e) {\n \\Sentry::captureException($e);\n }\n }\n\n public function saveNote(string $title, string $body, string $objectId, ?NoteObject $noteObject = null): ?string\n {\n $noteId = null;\n\n try {\n if ($this->profile->log_notes === Profile::LOG_NOTE_ENHANCED) {\n $noteId = $this->buildEnhancedNote($title, $body, $objectId);\n } else {\n $noteId = $this->buildClassicNote($title, $body, $objectId);\n }\n } catch (HttpNotFoundException $exception) {\n // The profile not having access to create Enhanced Notes. Set their preference to Classic.\n if ($this->profile->log_notes === Profile::LOG_NOTE_ENHANCED) {\n $this->profile->update([\n 'log_notes' => Profile::LOG_NOTE_CLASSIC,\n ]);\n }\n }\n\n return $noteId;\n }\n\n /**\n * This is using the \"Enhanced\" Notes feature, NOT the \"Notes & Attachments\" feature being deprecated.\n *\n * @url https://salesforce.stackexchange.com/questions/104408/how-can-i-create-an-account-note-or-contact-note-via-api-that-is-visible-in-sale\n */\n private function buildEnhancedNote(string $title, string $body, string $objectId): string\n {\n // Decode stored entities, escape HTML (without quoting), then convert line breaks for Salesforce formatting\n $decodedBody = html_entity_decode($body, ENT_QUOTES | ENT_HTML5);\n $sanitizedBody = htmlspecialchars($decodedBody, ENT_NOQUOTES, 'UTF-8', false);\n $content = nl2br($sanitizedBody, false);\n $note = [\n 'OwnerId' => $this->profile->crm_provider_id,\n 'Title' => $title,\n 'Content' => base64_encode($content),\n ];\n\n $noteId = $this->createRecord('ContentNote', $note);\n\n $link = [\n 'ContentDocumentId' => $noteId,\n 'LinkedEntityId' => $objectId,\n 'ShareType' => 'I',\n ];\n\n $this->createRecord('ContentDocumentLink', $link);\n\n return $noteId;\n }\n\n private function buildClassicNote(string $title, string $body, string $objectId): string\n {\n if (in_array($this->parseObjectType($objectId), [Field::OBJECT_TASK, Field::OBJECT_EVENT])) {\n $this->logger->info('[Salesforce] Summary not sent', [\n 'profile_id' => $this->profile->id,\n 'objectId' => $objectId,\n 'reason' => 'Classical Note does not support Task/Event relation',\n ]);\n\n return '';\n }\n\n $titleTrimmed = null;\n\n if (mb_strlen($title) > 80) {\n $titleTrimmed = substr($title, 0, 77) . '...';\n }\n $payload = [\n 'OwnerId' => $this->profile->crm_provider_id,\n 'IsPrivate' => false,\n 'Title' => $titleTrimmed ?? $title,\n 'Body' => $titleTrimmed ? $title . PHP_EOL . $body : $body,\n 'ParentId' => $objectId,\n ];\n\n return $this->createRecord('Note', $payload);\n }\n\n /**\n * @inheritdoc\n */\n public function find(string $name, array $scopes): array\n {\n if ($this->profile === null) {\n return [];\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $limitValues = ['limit' => $this->limit, 'offset' => $this->offset];\n $sosl = $queryBuilder->buildFindQuery($name, $scopes, $limitValues);\n\n $this->logger->info('[Salesforce] Find prospects', [\n 'profile_id' => $this->profile->id,\n 'sosl_query' => $sosl,\n 'search_string' => $name,\n 'scopes' => $scopes,\n ]);\n\n $data = Cache::remember($this->profile->id . $sosl, self::CACHE_TTL, function () use ($sosl) {\n $data = [];\n\n try {\n // Hit remote API.\n $objects = $this->queryHandler->search($sosl);\n\n // Build mapped list.\n foreach ($objects as $object) {\n $type = strtolower($object['attributes']['type']);\n\n $record = [\n 'crmId' => $object['Id'],\n 'name' => $object['Name'],\n 'prospectType' => $type,\n 'phoneNumbers' => [],\n 'crmUrl' => $this->generateProviderUrl($object['Id'], $type),\n ];\n\n switch ($type) {\n case 'lead':\n if (empty($object['Company']) === false) {\n $record['organization'] = $object['Company'];\n }\n\n if (empty($object['Title']) === false) {\n $record['title'] = $object['Title'];\n }\n\n $stage = $this->config->stages()\n ->where('type', Stage::TYPE_LEAD)\n ->where('name', $object['Status'])\n ->first();\n\n // Lazy create the stage.\n if ($stage === null) {\n $stage = $this->importStages([Stage::TYPE_LEAD], $object['Status']);\n }\n\n if ($stage) {\n $record += [\n 'stage' => [\n 'id' => $stage->id_string,\n 'name' => $stage->name,\n ],\n ];\n }\n\n if (empty($object['RecordTypeId']) === false) {\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $object['RecordTypeId'])\n ->first();\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n }\n\n break;\n\n case 'account':\n if (empty($object['Industry']) === false) {\n $record['industry'] = $object['Industry'];\n $record['detailsLine'] = $object['Industry'];\n }\n if (! empty($object['PersonEmail'])) {\n $record['detailsLine'] = $object['PersonEmail'];\n }\n\n break;\n\n case 'contact':\n // For contacts, we should try and fetch their account name too.\n if ($object['AccountId']) {\n // Cheaper to get this locally.\n $account = $this->config->accounts()\n ->where('crm_provider_id', $object['AccountId'])\n ->first(['name']);\n\n if ($account) {\n $record['organization'] = $account->name;\n }\n }\n\n if (! empty($object['IsPersonAccount']) && $object['Email']) {\n $record['detailsLine'] = $object['Email'];\n } else {\n if (empty($object['Title']) === false) {\n $record['title'] = $object['Title'];\n }\n }\n\n break;\n }\n\n // Add phone numbers to record.\n if (empty($object['Phone']) === false && $object['Phone']) {\n $record['phoneNumbers'][] = [\n 'number' => $object['Phone'],\n 'nationalFormat' => phone_national($this->profile->user->country_code, $object['Phone']),\n 'type' => 'phone',\n ];\n }\n\n if (empty($object['MobilePhone']) === false && $object['MobilePhone']) {\n $record['phoneNumbers'][] = [\n 'number' => $object['MobilePhone'],\n 'nationalFormat' => phone_national(\n $this->profile->user->country_code,\n $object['MobilePhone']\n ),\n 'type' => 'mobile',\n ];\n }\n\n $data[] = $record;\n }\n } catch (NoResultsException $e) {\n $data = [];\n }\n\n return $data;\n });\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n\n if ($crmAccountId === null) {\n return $data;\n }\n\n if ($userId) {\n $profileRepository = app(ProfileRepository::class);\n $profile = $profileRepository->findProfileByUserId($this->config, $userId);\n\n $ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;\n }\n\n try {\n // Perhaps their profile has no opportunity permissions.\n if ($this->profile === null || $this->profile->opportunity_fields === null) {\n return $data;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $query = $queryBuilder->buildFindOpportunitiesQuery();\n\n $objects = $this->queryHandler->query($query, ['accountId' => $crmAccountId]);\n\n foreach ($objects as $object) {\n $record = [\n 'crmId' => $object['Id'],\n 'name' => $object['Name'],\n 'won' => $object['IsWon'],\n 'closed' => $object['IsClosed'],\n ];\n\n $valueFieldName = 'Amount';\n if ($this->config->opportunity_value_field_id) {\n $valueFieldName = $this->config->opportunityValueField->crm_provider_id;\n }\n\n if (empty($object[$valueFieldName]) === false) {\n $currency = $object['CurrencyIsoCode'] ?? $this->config->default_currency;\n $value = formatCurrency($object[$valueFieldName], $currency);\n\n $record += [\n 'value' => $value,\n ];\n }\n\n $stage = $this->config->stages()\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->where('name', $object['StageName'])\n ->first();\n\n // Lazy create the stage.\n if ($stage === null) {\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $object['StageName']);\n }\n\n $record += [\n 'stage' => [\n 'id' => $stage->id_string,\n 'name' => $stage->name,\n ],\n ];\n\n if (empty($object['RecordTypeId']) === false) {\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $object['RecordTypeId'])\n ->first();\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n }\n\n if ($ownerId && isset($object['OwnerId']) && $object['OwnerId'] === $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n } catch (NoResultsException $e) {\n return $data;\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n public function getContactRolesFromCrm(?Carbon $since = null): array\n {\n $roles = [];\n\n if ($this->profile === null) {\n return $roles;\n }\n\n $queryBuilder = app(QueryBuilder::class, ['profile' => $this->profile]);\n\n $query = $queryBuilder->buildGetContactRolesQuery($since);\n\n try {\n $objects = $this->queryHandler->query($query);\n\n foreach ($objects as $object) {\n $roles[] = [\n 'id' => $object['Id'],\n 'contactId' => $object['ContactId'],\n 'opportunityId' => $object['OpportunityId'],\n 'ownerId' => $object['Opportunity']['OwnerId'] ?? null,\n 'isPrimary' => $object['IsPrimary'],\n 'role' => $object['Role'],\n ];\n }\n } catch (NoResultsException) {\n // Just return an empty array.\n $this->logger->info('[Salesforce] No contact roles found', [\n 'since' => $since?->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n }\n\n return $roles;\n }\n\n public function syncContactRoles(Carbon $since): int\n {\n $contactRoleRepository = app(ContactRoleRepository::class);\n\n $crmContactRoles = $this->getContactRolesFromCrm(since: $since);\n $syncCount = 0;\n $contactRoles = [];\n\n foreach ($crmContactRoles as $crmContactRole) {\n $contactRoles[] = $this->importContactRole($crmContactRole);\n $syncCount++;\n }\n\n $contactRoleRepository->saveContactRoles($contactRoles);\n\n $this->syncRemotelyDeletedContactRoles();\n\n return $syncCount;\n }\n\n private function importContactRole(array $contactRole): array\n {\n $contact = $this->config->contacts()\n ->where('crm_provider_id', $contactRole['contactId'])\n ->first();\n\n if ($contact === null) {\n $contact = $this->syncContact($contactRole['contactId']);\n }\n\n $opportunity = $this->config->opportunities()\n ->where('crm_provider_id', $contactRole['opportunityId'])\n ->first();\n\n if ($opportunity === null) {\n $opportunity = $this->syncOpportunity($contactRole['opportunityId']);\n }\n\n $role = null;\n if (! empty($contactRole['role'])) {\n $role = mb_strimwidth($contactRole['role'], 0, 191);\n }\n\n return [\n 'crm_configuration_id' => $this->config->getId(),\n 'contact_id' => $contact->getId(),\n 'crm_provider_id' => $contactRole['id'],\n 'subject_type' => ContactRole::SUBJECT_TYPE_OPPORTUNITY,\n 'subject_id' => $opportunity->getId(),\n 'is_primary' => $contactRole['isPrimary'],\n 'role' => $role,\n ];\n }\n\n protected function syncRemotelyDeletedContactRoles(): bool\n {\n try {\n $deletedRemotely = $this->queryHandler->queryDeleted('OpportunityContactRole');\n } catch (NoResultsException $e) {\n return false;\n }\n\n $deletedOpportunities = $deletedRemotely->getResults();\n $deletedIds = array_column($deletedOpportunities, 'id');\n\n $contactRoleRepository = app(ContactRoleRepository::class);\n\n foreach (array_chunk($deletedIds, self::HARD_DELETE_CHUNK) as $chunk) {\n $contactRoleRepository->deleteContactRoles($chunk);\n\n $this->logger->info('[' . $this->getDisplayName() . '] Remotely deleted opportunities synced', [\n 'teamId' => $this->team->id_string,\n 'remotelyDeletedOpportunities' => $chunk,\n 'count' => count($chunk),\n ]);\n }\n\n return true;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = $objects = [];\n\n $hasWho = \\in_array($objectType, ['lead', 'contact']);\n $playbook = $this->getPlaybook($this->profile->user);\n $fields = array_merge(\n $this->profile->getFieldsAsArray(parent::OBJECT_TASK),\n $playbook && $playbook->activityField ? [$playbook->activityField->crm_provider_id] : []\n );\n\n // Query should default to any open call for that user.\n $query = '\n SELECT ' . implode(',', array_unique($fields)) . '\n FROM Task\n WHERE OwnerId = :ownerId\n AND IsArchived = false\n AND IsDeleted = false\n AND IsClosed = false\n AND (';\n\n if ($objectType === 'account') {\n // This covers tasks tied to a related contact or opportunity too.\n $query .= '\n AccountId = :accountId';\n }\n\n if ($hasWho) {\n $query .= '\n WhoId = :whoId';\n\n // If we are also going to check on a specific opportunity, set that up.\n if ($opportunityId) {\n $query .= ' OR WhatId = :whatId';\n }\n }\n\n $query .= ' ) ORDER BY LastModifiedDate DESC';\n\n try {\n $objects = $this->queryHandler->query($query, [\n 'ownerId' => $this->profile->crm_provider_id,\n 'whoId' => $objectId,\n 'whatId' => $opportunityId,\n 'accountId' => $objectId,\n ]);\n } catch (NoResultsException $e) {\n return $data;\n } finally {\n $this->logger->debug(sprintf('[Salesforce] Found %s tasks for query \"%s\"', count($objects), $query));\n }\n\n foreach ($objects as $object) {\n $dueDate = $object['ActivityDate'] ? Carbon::parse($object['ActivityDate'])->toIso8601String() : null;\n $data[] = [\n 'crmId' => $object['Id'],\n 'subject' => $object['Subject'],\n 'due' => $dueDate,\n 'type' => $object[$playbook->activityField->crm_provider_id],\n ];\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getEvents(string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = $objects = [];\n $user = $this->profile?->user;\n if ($this->profile === null || $user === null) {\n return $data;\n }\n\n $hasWho = \\in_array($objectType, ['lead', 'contact']);\n $playbook = $this->getPlaybook($user);\n $fields = array_merge(\n $this->profile->getFieldsAsArray(parent::OBJECT_EVENT),\n $playbook && $playbook->activityField ? [$playbook->activityField->crm_provider_id] : []\n );\n\n // Query should default to any event starting in the last week and ending up until today owned by the user.\n $query = '\n SELECT ' . implode(',', array_unique($fields)) . '\n FROM Event\n WHERE OwnerId = :ownerId\n AND IsArchived = false\n AND IsAllDayEvent = false\n AND StartDateTime >= LAST_N_DAYS:7\n AND EndDateTime <= TODAY\n AND (';\n\n if ($objectType === 'account') {\n // This covers events tied to a related contact or opportunity too.\n $query .= '\n AccountId = :accountId';\n }\n\n if ($hasWho) {\n $query .= '\n WhoId = :whoId';\n\n // If we are also going to check on a specific opportunity, set that up.\n if ($opportunityId) {\n $query .= ' OR WhatId = :whatId';\n }\n }\n\n $query .= ' ) ORDER BY LastModifiedDate DESC';\n\n try {\n $objects = $this->queryHandler->query($query, [\n 'ownerId' => $this->profile->crm_provider_id,\n 'whoId' => $objectId,\n 'whatId' => $opportunityId,\n 'accountId' => $objectId,\n ]);\n } catch (NoResultsException $e) {\n return $data;\n } finally {\n $this->logger->debug(sprintf('[Salesforce] Found %s tasks for query \"%s\"', count($objects), $query));\n }\n\n foreach ($objects as $object) {\n $dueDate = $object['StartDateTime'] ? Carbon::parse($object['StartDateTime'])->toIso8601String() : null;\n\n $data[] = [\n 'crmId' => $object['Id'],\n 'subject' => $object['Subject'],\n 'due' => $dueDate,\n 'type' => $object[$playbook->activityField->crm_provider_id],\n ];\n }\n\n return $data;\n }\n\n /**\n * Try to find CRM Objects using email address\n *\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchExactlyByEmail(string $email, ?int $userId = null): ?array\n {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $sosl = $queryBuilder->buildMatchByQuery($email, Field::TYPE_EMAIL);\n if ($sosl === null) {\n return null;\n }\n\n try {\n $objects = $this->queryHandler->search($sosl);\n $objects = $this->queryHandler->prioritiseResults(\n $objects,\n $email,\n QueryHandler::PRIORITISE_EMAIL\n );\n\n $data = $this->convertCrmData($objects, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (NoResultsException $e) {\n // Try the account next.\n if ($this->profile->account_fields === null) {\n return null;\n }\n }\n\n return null;\n }\n\n public function getDomain(string $email): ?string\n {\n // SF improved search - strip the domain extension, min domain name length 4\n return $this->getCompanyNameFromEmail(email: $email, minNameLength: 4);\n }\n\n /**\n * Try to find CRM objects using domain name of the email address\n *\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByDomain(string $domain, ?int $userId = null): ?array\n {\n $companyName = $domain;\n\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $sosl = $queryBuilder->buildMatchByDomainQuery($companyName);\n\n try {\n $objects = $this->queryHandler->search($sosl);\n\n $data = $this->convertCrmData($objects, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (NoResultsException) {\n return null;\n }\n }\n\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n // Don't bother looking up numbers that are masked.\n if (str_contains($phone, '**')) {\n return null;\n }\n\n if ($this->isPhoneNumberOfTeamMember($phone)) {\n return null;\n }\n\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $phoneNational = phone_national(null, $phone) ?? '';\n $possiblePhoneFormats = collect([\n preg_replace('/\\D/', '', ltrim($phone, '0+')),\n preg_replace('/\\D/', '', $phoneNational),\n formatDashPhoneNumber($phone),\n $phoneNational,\n ])\n ->filter() // Removes null and empty strings\n ->unique()\n ->values();\n\n foreach ($possiblePhoneFormats as $phone) {\n $sosl = $queryBuilder->buildMatchByQuery($phone, Field::TYPE_PHONE);\n if ($sosl === null) {\n continue;\n }\n\n try {\n $objects = $this->queryHandler->search($sosl);\n $objects = $this->queryHandler->prioritiseResults(\n $objects,\n $phone,\n QueryHandler::PRIORITISE_PHONE\n );\n\n return $this->convertCrmData($objects, $userId);\n } catch (NoResultsException) {\n continue;\n }\n }\n\n return null;\n }\n\n private function isPhoneNumberOfTeamMember(string $phone): bool\n {\n $teamRepository = app(TeamRepository::class);\n $user = $teamRepository->findTeamMemberByPhone($this->team, $phone);\n\n if ($user instanceof User) {\n return true;\n }\n\n return false;\n }\n\n protected function getCacheKey(string $object, ?int $userId = null): ?string\n {\n $key = $this->profile->id . $object;\n $keySuffix = $this->getOwnerKeySuffix($userId);\n\n return $key . $keySuffix;\n }\n\n private function getOwnerKeySuffix(?int $userId = null): string\n {\n return $userId === null ? '' : (string) $userId;\n }\n\n /** Determine the CRM Objects which represent the call activity. */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n if ($this->profile === null) {\n return null;\n }\n\n $cacheKey = $this->getCacheKey($name, $userId);\n\n $result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $sosl = $queryBuilder->buildMatchByQuery($name, 'name');\n if ($sosl === null) {\n return false;\n }\n\n try {\n $objects = $this->queryHandler->search($sosl);\n } catch (NoResultsException $e) {\n return false;\n }\n\n $objects = $this->queryHandler->prioritiseResults(\n $objects,\n $name,\n QueryHandler::PRIORITISE_NAME\n );\n\n $data = $this->convertCrmData($objects, $userId);\n\n return (! empty(array_filter($data))) ? $data : false;\n });\n\n return is_array($result) ? $result : null;\n }\n\n /**\n * @return array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n protected function convertCrmData(QueryIterator $objects, ?int $userId = null): array\n {\n $lead = null;\n $contact = null;\n $opportunity = null;\n $account = null;\n $stage = null;\n $countryCode = null;\n\n if ($objects->count() > 0) {\n $object = $objects->current();\n\n if ($object['attributes']['type'] === 'Lead') {\n $lead = $this->importLead($object);\n\n // Lead might not be imported if the Stage is null for example.\n if ($lead) {\n $countryCode = $lead->country_code;\n $stage = $lead->stage;\n }\n } else {\n if ($object['attributes']['type'] === 'Contact') {\n $contact = $this->importContact($object);\n $account = $contact->account;\n } else {\n $account = $this->importAccount($object);\n }\n\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account) {\n $countryCode = $account->country_code;\n }\n\n try {\n $sfOpportunities = $this->findOpportunities(\n $account?->getCrmProviderId(),\n $contact?->getCrmProviderId(),\n $userId\n );\n\n // Take the first opportunity, which will be ordered as priority based on their settings.\n if (! empty($sfOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($sfOpportunities[0]['crmId']);\n $stage = $opportunity?->stage;\n }\n } catch (Exception) {\n // Nothing to see here.\n }\n }\n }\n\n return [\n $lead,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n /**\n * @inheritdoc\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n if ($stage->type === Stage::TYPE_LEAD) {\n $objectType = 'Lead';\n $objectId = $crmObject->crm_provider_id;\n $objectStageType = 'Status';\n } else {\n $objectType = 'Opportunity';\n $objectId = $crmObject->crm_provider_id;\n $objectStageType = 'StageName';\n }\n\n $headers = [];\n if ($this->config->trigger_assignment_rules === false) {\n // @see: https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/headers_autoassign.htm\n $headers = [\n 'Sforce-Auto-Assign' => 'false',\n ];\n }\n\n $this->updateRecord($objectType, $objectId, [$objectStageType => $stage->name], $headers);\n }\n\n public function parseObjectType(string $objectId): string\n {\n if (Str::startsWith($objectId, '001')) {\n return 'account';\n }\n\n if (Str::startsWith($objectId, '003')) {\n return 'contact';\n }\n\n if (Str::startsWith($objectId, '00Q')) {\n return 'lead';\n }\n\n if (Str::startsWith($objectId, '006')) {\n return 'opportunity';\n }\n\n if (Str::startsWith($objectId, '00U')) {\n return 'event';\n }\n\n if (Str::startsWith($objectId, '00T')) {\n return 'task';\n }\n\n throw new \\InvalidArgumentException('Unsupported Object Type');\n }\n\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, ['profile' => $this->profile]);\n $query = $queryBuilder->buildGetUsersQuery($userToSearch);\n\n try {\n $salesforceUsers = $this->queryHandler->query($query, [\n 'active' => true,\n ]);\n } catch (NoResultsException $e) {\n $this->logger->info('[Salesforce] Sync Profiles. No users found', [\n 'query' => $query,\n 'error' => $e->getMessage(),\n ]);\n\n return null;\n }\n\n $teamRepository = app(TeamRepository::class);\n $customRules = $this->getCustomProfileRules($teamRepository);\n\n foreach ($salesforceUsers as $crmUser) {\n if ($crmUser['Email'] === null) {\n continue;\n }\n\n if (! $this->customProfileValidation($crmUser, $customRules)) {\n continue;\n }\n\n $user = $teamRepository->findActiveTeamMemberByEmail($this->team, $crmUser['Email']);\n\n if (! $user instanceof User) {\n continue;\n }\n\n $edition = $crmUser['UserPreferencesLightningExperiencePreferred']\n ? Profile::EDITION_LIGHTNING\n : Profile::EDITION_CLASSIC;\n\n $profileRepository = app(ProfileRepository::class);\n $profile = $profileRepository->updateOrCreateProfile(\n $user,\n [\n 'crm_configuration_id' => $this->config->getId(),\n 'crm_provider_id' => $crmUser['Id'],\n ],\n [\n 'user_id' => $user->getId(),\n 'edition' => $edition,\n 'has_external_cti' => ! empty($crmUser['CallCenterId']),\n 'crm_profile_id' => $crmUser['ProfileId'],\n ]\n );\n\n if ($userToSearch instanceof User && $userToSearch->getId() === $user->getId()) {\n return $profile;\n }\n }\n\n // Clean up inactive profiles\n try {\n $this->archiveInactiveProfiles();\n } catch (\\Exception $e) {\n $this->logger->warning('[Salesforce] Profile archiving failed', [\n 'teamId' => $this->team->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $url = null;\n\n // For Salesforce it's easy, we just point every object to the apex domain and they handle it.\n switch ($objectType) {\n case 'lead':\n case 'account':\n case 'contact':\n case 'opportunity':\n case 'task':\n case 'event':\n case 'activity':\n\n $url = $this->config->crm_base_url . '/' . $providerId;\n\n break;\n }\n\n return $url;\n }\n\n public function buildTaskSearchFields(): array\n {\n return ['Id', 'WhoId', 'WhatId', 'AccountId'];\n }\n\n public function getTaskByFilterConditions(\n array $fields,\n array $filters,\n bool $bulkSearch = false,\n bool $strictFilters = true\n ): ?array {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $query = $queryBuilder->buildSearchTaskQuery($fields, $filters, $bulkSearch, $strictFilters);\n\n try {\n if (! $bulkSearch) {\n $objects = $this->queryHandler->query($query, $filters);\n if ($objects->count() === 1) {\n return $objects->current();\n }\n }\n\n if ($bulkSearch) {\n $objects = $this->queryHandler->query($query);\n $records = [];\n foreach ($objects as $record) {\n $key = $record[end($fields)];\n $records[$key] = $record;\n }\n\n return $records;\n }\n } catch (\\Exception $e) {\n $this->logger->info('[Salesforce] Failed to execute query', [\n 'query' => $query,\n 'error' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function mapCrmObjects(array $task): array\n {\n $activityData = [];\n\n if (! empty($task['WhoId'])) {\n $type = $this->parseObjectType($task['WhoId']);\n $activityData[$type] = $task['WhoId'];\n }\n if (! empty($task['AccountId'])) {\n $activityData['account'] = $task['AccountId'];\n }\n if (! empty($task['WhatId'])) {\n $activityData['opportunity'] = $task['WhatId'];\n }\n\n return $activityData;\n }\n\n /**\n * Get SF task by Outreach call id.\n */\n public function getTaskByFilter(\n string $activityFieldType,\n array $filters,\n string $operator = '=',\n array $additionalFields = []\n ): ?array {\n $data = [];\n\n try {\n // Default (base) fields.\n $fields = ['Id', 'Subject', 'Description', 'ActivityDate', 'WhoId', 'WhatId', $activityFieldType];\n\n foreach ($additionalFields as $additionalField) {\n $fields[] = $additionalField->crm_provider_id;\n }\n\n $fields = array_unique($fields);\n\n // Find task with the same Outreach id as the call id.\n $query = 'SELECT ' . implode(',', $fields) . '\n FROM Task\n WHERE IsArchived = false AND IsDeleted = false';\n\n foreach ($filters as $key => $value) {\n $key = preg_quote($key, '/');\n $key = str_replace(['\\'', '\"'], '', $key);\n // Prepare the substitution.\n $strKey = \":$key\";\n\n $query .= \" AND $key $operator $strKey\";\n }\n\n $query .= ' ORDER BY LastModifiedDate DESC LIMIT 1';\n\n $objects = $this->queryHandler->query($query, $filters);\n\n // There should be only one task related to this call if any.\n if ($objects->count() === 1) {\n $object = $objects->current();\n\n $dueDate = $object['ActivityDate'] ? Carbon::parse($object['ActivityDate'])->toIso8601String() : null;\n\n $data = array_merge($object, [\n 'crmId' => $object['Id'],\n 'subject' => $object['Subject'],\n 'summary' => $object['Description'],\n 'due' => $dueDate,\n 'Type' => $object[$activityFieldType],\n ]);\n }\n } catch (NoResultsException $e) {\n // Filters don't match any records.\n } catch (ServiceUnavailableException $serviceUnavailableException) {\n // Service cannot be queried. We should probably log this.\n }\n\n return $data;\n }\n\n /**\n * Get Salesforce fields including datetime fields\n *\n * @param $objectType\n */\n private function getAllFieldsAsArray($objectType): array\n {\n $basicFields = [];\n // Not all users have access to all object fields.\n if ($this->profile->{$objectType . '_fields'}) {\n $basicFields = explode(',', $this->profile->{$objectType . '_fields'});\n }\n\n $extraFields = [\n 'CreatedDate',\n 'LastModifiedDate',\n 'IsDeleted',\n ];\n\n if ($objectType === self::OBJECT_OPPORTUNITY\n && $this->config->opportunity_value_field_id\n && ! in_array($this->config->opportunityValueField->crm_provider_id, $basicFields)\n ) {\n $extraFields[] = $this->config->opportunityValueField->crm_provider_id;\n }\n\n return array_unique(array_merge($basicFields, $extraFields));\n }\n\n /**\n * Generate transcription for activity description.\n */\n private function generateTranscription(Activity $activity): string\n {\n if (! ($this->config->store_transcript)) {\n // If sending transcription to activity toggle is disabled\n return '';\n }\n\n return $this->transcriptionService\n ->findTranscriptionByActivity($activity)\n ->map(static function (array $transcriptionSegment): string {\n return $transcriptionSegment['formattedStartsAt'] . ' | ' . $transcriptionSegment['transcript'];\n })\n ->implode(PHP_EOL);\n }\n\n /**\n * Find related Salesforce event based on activity data\n *\n * @return array<string>\n */\n public function fetchRelatedActivity(Activity $activity): array\n {\n $this->logger->info('[Salesforce] Searching for related activity', [\n 'activityId' => $activity->getUuid(),\n 'ownerId' => $this->profile?->crm_provider_id,\n ]);\n\n $sfEvent = $this->fetchRelatedEvent($activity);\n if (empty($sfEvent)) {\n $this->logger->info('[Salesforce] No related activity found', [\n 'activityId' => $activity->getUuid(),\n 'ownerId' => $this->profile?->crm_provider_id,\n 'account' => $activity->hasAccount()\n ? $activity->getAccount()->getCrmProviderId()\n : null,\n ]);\n\n return [];\n }\n\n return $sfEvent;\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n if ($activity->isTypeConference() === false) {\n return null;\n }\n\n if ($activity->hasActualStartTime() === false && $activity->hasScheduledStartTime() === false) {\n return null;\n }\n\n if (! $activity->hasProspect()) {\n $this->logger->info('[Salesforce] Skip look up, Activity not linked to Lead, Contact or Account', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n $playbook = $this->getPlaybook($activity->getUser());\n if ($playbook !== null && $playbook->getActivityType() === Playbook::ACTIVITY_TYPE_TASK) {\n $this->logger->info('[Salesforce] Skip auto-sync for task-based playbook', [\n 'activityUuid' => $activity->getUuid(),\n 'playbookId' => $playbook->getId(),\n 'playbookType' => $playbook->getActivityType(),\n ]);\n\n return null;\n }\n\n try {\n $sfEvent = $this->fetchRelatedActivity($activity);\n if (empty($sfEvent)) {\n return null;\n }\n\n [$activityField, $activityType] = $this->resolveActivityTypeFromEvent($activity, $sfEvent);\n\n $this->logger->info('[Salesforce] Found related activity', [\n 'activityId' => $activity->getUuid(),\n 'sfEvent' => $sfEvent['Id'],\n 'activityFieldName' => $activityField,\n 'crmActivityType' => ($activityField !== null && isset($sfEvent[$activityField]))\n ? $sfEvent[$activityField]\n : null,\n 'activityType' => $activityType,\n ]);\n\n $userId = $this->findRelatedActivityUserId($activity, $sfEvent);\n\n if ($activity->getUserId() !== $userId) {\n $this->logger->info('[Salesforce] Updating meeting owner', [\n 'activityId' => $activity->getUuid(),\n 'oldUserId' => $activity->getUserId(),\n 'newUserId' => $userId,\n ]);\n }\n\n $this->updateSfEventDescription($activity, $sfEvent);\n\n $activity->update([\n 'user_id' => $userId,\n 'crm_provider_id' => $sfEvent['Id'],\n 'playbook_category_id' => $activityType->id ?? $activity->getCategory()?->getId(),\n ]);\n\n $this->logger->info('[Salesforce] Activity updated', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return $activity;\n } catch (\\Exception $exception) {\n \\Sentry::captureException($exception);\n\n throw $exception;\n }\n }\n\n /**\n * @param array<string, mixed> $sfEvent\n *\n * @return array{0: string|null, 1: mixed}\n */\n private function resolveActivityTypeFromEvent(Activity $activity, array $sfEvent): array\n {\n $activityField = $this->getActivityFieldName($activity);\n $activityType = null;\n\n if ($activityField !== null && ! empty($sfEvent[$activityField])) {\n $playbook = $this->getPlaybook($activity->getUser());\n $activityType = $this->getPlaybookCategory($playbook, strval($sfEvent[$activityField]));\n }\n\n return [$activityField, $activityType];\n }\n\n /**\n * @param array<string> $sfEvent\n */\n private function findRelatedActivityUserId(Activity $activity, array $sfEvent): int\n {\n $userId = $activity->getUserId();\n\n if (empty($sfEvent['OwnerId']) === false) {\n $profile = $this\n ->config\n ->profiles()\n ->where('crm_provider_id', $sfEvent['OwnerId'])\n ->get()\n ->filter(static function (Profile $profile) use ($activity): bool {\n if (! $activity->isTypeConference()) {\n return ! empty($profile->user) ? $profile->user->isStatusActive() : false;\n }\n\n $participants = $activity->getParticipants();\n\n return ! empty($profile->user)\n ? $profile->user->isStatusActive()\n && $profile->user->hasPermission(PermissionEnum::RECORD_MEETING)\n && $participants->contains('user_id', $profile->user_id)\n : false;\n })\n ->first();\n\n if ($profile) {\n $userId = $profile->user_id;\n }\n }\n\n return $userId;\n }\n\n /**\n * @param array<string, mixed> $sfEvent\n */\n private function updateSfEventDescription(Activity $activity, array $sfEvent): void\n {\n try {\n if (str_contains($sfEvent['Description'], $activity->id_string)) {\n return;\n }\n\n $payload = [\n 'Description' => $sfEvent['Description']\n . PHP_EOL\n . PHP_EOL\n . (new DecorateActivity())->generateDescription($activity),\n ];\n\n $this->logger->info('[Salesforce] Update record', [\n 'activityId' => $activity->getUuid(),\n 'sfEvent' => $sfEvent['Id'],\n 'payload' => $payload,\n ]);\n\n $payload = array_merge(\n $payload,\n $this->payloadBuilder->fetchCustomFieldData($activity, Field::OBJECT_EVENT)\n );\n\n $this->updateRecord('Event', $sfEvent['Id'], $payload);\n } catch (\\Exception) {\n $this->logger->error('[Salesforce] Failed to update record', [\n 'activityUuid' => $activity->getUuid(),\n 'sfEvent' => $sfEvent['Id'],\n ]);\n }\n }\n\n /**\n * Returns the most recently modified Event within time range (if any).\n *\n * @return array|null An Event record from Salesforce.\n */\n private function fetchRelatedEvent(Activity $activity): ?array\n {\n $ownerId = $this->profile?->crm_provider_id;\n if ($ownerId === null) {\n return [];\n }\n\n /** @var ?Carbon $from */\n /** @var ?Carbon $to */\n [$from, $to] = $this->getFromToDates($activity);\n\n try {\n $whoId = null;\n $hasWho = $activity->lead_id || $activity->contact_id;\n if ($hasWho) {\n $whoId = $activity->hasLead()\n ? $activity->getLead()->crm_provider_id\n : $activity->getContact()->crm_provider_id;\n }\n\n if ($hasWho === false && $activity->account_id === null) {\n return null;\n }\n\n $query = $this->buildFetchRelatedEventQuery($activity);\n\n $objects = $this->queryHandler->query($query, [\n 'ownerId' => $ownerId,\n 'whoId' => $whoId,\n 'whatId' => $activity->hasOpportunity() ? $activity->getOpportunity()->crm_provider_id : null,\n 'accountId' => $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null,\n 'from' => $from?->format('Y-m-d\\TH:i:s\\Z'),\n 'to' => $to?->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($objects as $object) {\n return $object;\n }\n } catch (NoResultsException $e) {\n return [];\n }\n\n return [];\n }\n\n private function getFromToDates(Activity $activity): array\n {\n $from = null;\n $to = null;\n\n /** @var ?CalendarEvent $calendarEvent */\n $calendarEvent = $activity->calendarEvent()->first();\n if ($calendarEvent !== null) {\n $from = $calendarEvent->getStartTime();\n $to = $calendarEvent->getEndTime();\n }\n\n // For non-calendar imported activities\n // Also double check if calendar event dates could be null?\n // If null use what we've got so far\n if ($from === null || $to === null) {\n $from = $activity->hasScheduledStartTime()\n ? $activity->getScheduledStartTime()\n : $activity->getActualStartTime();\n $to = $activity->hasScheduledEndTime()\n ? $activity->getScheduledEndTime()->addMinutes(15)\n : $activity->getActualEndTime();\n }\n\n return [$from, $to];\n }\n\n /**\n * Determines the appropriate activity field name for querying Salesforce events.\n *\n * This method follows a hierarchy to determine the field name:\n * 1. Uses the playbook's activity field if it exists and is in the profile's accessible fields\n * 2. Falls back to the default activity field if the profile has no event fields configured\n * 3. Returns null if no suitable field is found\n *\n * @param Activity $activity The activity to determine the field for\n *\n * @return string|null The field name to use in queries, or null if none is available\n */\n private function getActivityFieldName(Activity $activity): ?string\n {\n if ($this->profile === null) {\n $this->logger->warning('[Salesforce] Cannot determine activity field - profile not found', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n $profileEventFields = $this->profile->getFieldsAsArray('event');\n\n if (empty($profileEventFields)) {\n $defaultActivityField = $this->getDefaultActivityField(Field::OBJECT_EVENT);\n $defaultFieldName = $defaultActivityField?->getAttribute('crm_provider_id');\n // Profile not yet synced — fall back to the default activity field.\n // There is a small chance that the profile won't have Default Activity Type field access\n // in which case the query will fail.\n // This is however an edge case and should be reviewed for profile sync issues.\n Sentry::withScope(function (\\Sentry\\State\\Scope $scope) use ($defaultFieldName): void {\n $scope->setContext('details', [\n 'profileId' => $this->profile->id,\n 'defaultField' => $defaultFieldName,\n ]);\n Sentry::captureMessage(\n '[Salesforce] Profile event fields empty, falling back to default activity field.',\n \\Sentry\\Severity::warning()\n );\n });\n\n return $defaultFieldName;\n }\n\n $playbook = $this->getPlaybook($activity->getUser());\n\n if (! is_null($playbook) && ! is_null($playbook->getActivityField())) {\n $playbookFieldName = $playbook->getActivityField()->getAttribute('crm_provider_id');\n\n if (in_array($playbookFieldName, $profileEventFields, true)) {\n return $playbookFieldName;\n }\n\n $this->logger->warning('[Salesforce] Playbook activity field not found in profile fields', [\n 'activityId' => $activity->getUuid(),\n 'playbookField' => $playbookFieldName,\n 'profileId' => $this->profile->id,\n ]);\n }\n\n return null;\n }\n\n private function buildFetchRelatedEventQuery(Activity $activity): string\n {\n $hasWho = $activity->lead_id || $activity->contact_id;\n\n $activityFieldName = $this->getActivityFieldName($activity);\n $fields = array_filter(['Id', 'Description', 'OwnerId', $activityFieldName]);\n\n $ownerCondition = '(OwnerId = :ownerId OR CreatedById = :ownerId)';\n\n $query = '\n SELECT ' . implode(',', $fields) . '\n FROM Event\n WHERE ' . $ownerCondition . '\n AND IsArchived = false\n AND IsAllDayEvent = false\n AND StartDateTime >= :from\n AND EndDateTime <= :to\n AND (';\n\n $operator = '';\n if ($activity->account_id) {\n // This covers events tied to a related contact or opportunity too.\n $query .= 'AccountId = :accountId';\n\n $operator = ' OR ';\n }\n\n if ($hasWho) {\n $query .= $operator . 'WhoId = :whoId';\n\n // If we are also going to check on a specific opportunity, set that up.\n if ($activity->opportunity_id) {\n $query .= ' OR WhatId = :whatId';\n }\n }\n\n $query .= ') ORDER BY LastModifiedDate DESC';\n\n return $query;\n }\n\n public function fetchProspect(array $task): array\n {\n $lead = $account = $opportunity = $contact = $stage = $countryCode = null;\n $externalId = $task['WhoId'] ?? null;\n\n // Lead or Contact\n if ($externalId) {\n try {\n [$lead, $account, $opportunity, $contact, $stage, $countryCode] = $this->parseRecords($externalId);\n } catch (\\InvalidArgumentException $exception) {\n // Invalid object type.\n }\n }\n\n // If we happen to know the opportunity or account from the Task, figure that out.\n if (empty($task['WhatId']) === false) {\n // WhatId could be either Account ID or Opportunity ID.\n // If WhatId is Opportunity ID, get the opportunity and stage from the CRM.\n try {\n [, $account, $opportunity, , $stage, ] = $this->parseRecords($task['WhatId']);\n } catch (\\InvalidArgumentException $exception) {\n // Invalid object type.\n }\n }\n\n return [$lead, $account, $opportunity, $contact, $stage, $countryCode];\n }\n\n /**\n * Save activity transcription summary as note\n */\n public function saveTranscriptionSummaryAsNote(\n ActivityContract $activity,\n string $title,\n string $body,\n ?string $objectId,\n ?NoteObject $noteObject = null,\n ): ?string {\n return $this->saveNote($title, $body, (string) $objectId);\n }\n\n public function getObjectByFilterConditions(string $objectType, array $fields, array $filters): ?array\n {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $query = $queryBuilder->buildObjectSearchQuery($objectType, $fields, $filters);\n\n try {\n $objects = $this->queryHandler->query($query, $filters);\n if ($objects->count() === 1) {\n return $objects->current();\n }\n } catch (\\Exception $e) {\n $this->logger->info('[Salesforce] Failed to execute query', [\n 'query' => $query,\n 'error' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n private function getCustomProfileRules(TeamRepository $teamRepository): array\n {\n $teamSettings = $teamRepository->getTeamSetting($this->team, 'custom_profile_validation');\n\n if ($teamSettings instanceof TeamSettings && $teamSettings->getValueType() === 'array') {\n $customRules = json_decode($teamSettings->getValue(), true);\n if (is_array($customRules)) {\n return $customRules;\n }\n }\n\n return [];\n }\n\n private function customProfileValidation(array $crmUser, array $customRules): bool\n {\n foreach ($customRules as $customRule) {\n if ($crmUser[$customRule['field']] !== $customRule['value']) {\n return false;\n }\n }\n\n return true;\n }\n\n /**\n * When syncing Contact / Lead / Account / Opportunity / Stage crm entities,\n * validate and restore locally trashed objects,\n * before updating them. Objects are identified by CrmProviderId\n */\n private function restoreAnyTrashedEntity(HasMany $targetEntity, string $crmProviderId): void\n {\n $recordExists = $targetEntity->withTrashed()->where(['crm_provider_id' => $crmProviderId])->first();\n if ($recordExists && $recordExists->trashed()) {\n $recordExists->restore();\n }\n }\n\n #[\\Override] public function supportsNotes(): bool\n {\n return true;\n }\n\n private function getOwnerProfile(?string $ownerId): ?Profile\n {\n if ($ownerId === null) {\n return null;\n }\n\n return $this->config->profiles()\n ->where('crm_provider_id', $ownerId)\n ->first();\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"Current version","depth":1,"on_screen":true,"value":"Current version","help_text":"text/plain","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Salesforce;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse Illuminate\\Database\\Eloquent\\Relations\\HasMany;\nuse Illuminate\\Events\\Dispatcher;\nuse Illuminate\\Support\\Facades\\Cache;\nuse Illuminate\\Support\\Facades\\Log;\nuse Illuminate\\Support\\Str;\nuse Jiminny\\Component\\Country\\CountriesMap;\nuse Jiminny\\Contracts\\Acl\\PermissionEnum;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\FetchRelatedActivityInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\ImportsBusinessProcessesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\SalesforceBatchSyncInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\SalesforceInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteNoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SearchTaskInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SendSummaryToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SupportsObjectTypeParseInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmProfileRecordTypesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Enums\\CrmObject;\nuse Jiminny\\Events\\Activities\\Crm\\LeadConverted;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpBadRequestException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Exceptions\\NoResultsException;\nuse Jiminny\\Exceptions\\ServiceUnavailableException;\nuse Jiminny\\Jobs\\Crm\\NoteObject;\nuse Jiminny\\Jobs\\Crm\\MatchActivitiesToNewOpportunity;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Calendar\\CalendarEvent;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Contracts\\ActivityContract;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Configuration;\nuse Jiminny\\Models\\Crm\\ContactRole;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Crm\\RecordType;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\TeamSettings;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\ContactRoleRepository;\nuse Jiminny\\Repositories\\Crm\\FieldRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Repositories\\Crm\\RecordTypeFieldValuesRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\Helpers\\ArrayIterator;\nuse Jiminny\\Services\\Crm\\MatchDomainByEmailInterface;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Services\\Crm\\ResolveCompanyNameByEmailTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\Fields\\FieldHelper;\nuse Jiminny\\Services\\Crm\\Salesforce\\Fields\\FieldTypeConverter;\nuse Jiminny\\Services\\Crm\\Salesforce\\Fields\\ValueNormalizer;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\FollowupActivityTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\LogActivityTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\RecordManipulationsTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\SyncFieldsTrait;\nuse Jiminny\\Utils\\CurrencyFormatter;\nuse Jiminny\\Utils\\StringUtil;\nuse Ramsey\\Uuid\\Uuid;\nuse Sentry\\Laravel\\Facade as Sentry;\n\nclass Service extends BaseService implements\n SalesforceInterface,\n SalesforceBatchSyncInterface,\n SyncCrmEntitiesInterface,\n SyncCrmProfileRecordTypesInterface,\n ImportsBusinessProcessesInterface,\n RemoteEntityManipulationInterface,\n FetchRelatedActivityInterface,\n SendSummaryToCrmInterface,\n MatchDomainByEmailInterface,\n SearchTaskInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n SupportsObjectTypeParseInterface,\n RemoteNoteEntityManipulationInterface,\n VerifyTaskExistsInterface\n{\n use ResolveCompanyNameByEmailTrait;\n use SyncFieldsTrait;\n use DeleteObjectsTrait;\n use RecordManipulationsTrait;\n use ServiceTraits\\BatchSyncTrait;\n use FollowupActivityTrait;\n use LogActivityTrait;\n\n /**\n * Note Body Limit for the Old Note-Taking Tool\n *\n * @var int\n */\n private const int CLASSIC_NOTE_MAX_LENGTH = 32000;\n\n /**\n * Note Content Limit for the New Notes\n *\n * @var int\n */\n private const int ENHANCED_NOTE_MAX_LENGTH = 50000000;\n\n private const string INSTALLED_PACKAGE_ID = '033Tw0000007bKbIAI';\n\n private const int CACHE_TTL = 600;\n\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day - 86400\n\n /**\n * @var Client\n */\n protected $client;\n\n protected PayloadBuilder $payloadBuilder;\n protected QueryHandler $queryHandler;\n\n private OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n\n public function __construct(\n Client $client,\n PayloadBuilder $payloadBuilder,\n protected Dispatcher $eventDispatcher,\n private readonly CountriesMap $countriesMap,\n private readonly ProspectPhotoPathService $prospectPhotoPathService,\n ) {\n parent::__construct();\n\n $this->client = $client;\n $this->payloadBuilder = $payloadBuilder;\n $this->queryHandler = app(QueryHandler::class, [\n 'client' => $this->client,\n 'logger' => $this->logger,\n ]);\n $this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n }\n\n public function getDisplayName(): string\n {\n return 'Salesforce';\n }\n\n public function getJobDelay(): int\n {\n return 1;\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n return $user->getSocialAccount(SocialAccount::PROVIDER_SALESFORCE);\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($activity, $crmProviderId) {\n $playbook = $this->getPlaybookFromActivity($activity);\n\n if ($playbook === null) {\n $this->logger->warning('[Salesforce] Cannot verify task - no playbook found', [\n 'activity' => $activity->getId(),\n 'crm_provider_id' => $crmProviderId,\n ]);\n\n return false;\n }\n\n $objectType = $playbook->getActivityType() === Playbook::ACTIVITY_TYPE_EVENT ? 'Event' : 'Task';\n\n try {\n $record = $this->getRecord($objectType, $crmProviderId, ['Id', 'IsDeleted']);\n\n return ! empty($record) && ($record['IsDeleted'] ?? false) === false;\n } catch (HttpNotFoundException|HttpBadRequestException) {\n $this->logger->info('[Salesforce] Activity record not found during verification', [\n 'activity' => $activity->getId(),\n 'object_type' => $objectType,\n 'crm_provider_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n });\n }\n\n public function query(string $queryToRun, array $parameters = []): QueryIterator\n {\n // Due to poorly designed external calls, this method cannot be entirely removed\n return $this->queryHandler->query($queryToRun, $parameters);\n }\n\n /*=========== Organization Information ===============*/\n\n /**\n * Get a list of all the API Versions for the instance.\n *\n * @throws CrmException\n *\n * @return mixed\n *\n */\n public function getApiVersions()\n {\n $url = $this->config->crm_base_url . '/services/data';\n\n $response = $this->client->get($url);\n\n return json_decode($response->getBody(), true);\n }\n\n /**\n * Gets the valid recordTypes for a given Salesforce Object via the describe API.\n */\n private function getRecordTypes(string $crmObject): array\n {\n $url = $this->client->getObjectsUrl() . $crmObject . '/describe';\n\n $response = $this->client->get($url);\n $jsonResponse = json_decode($response->getBody(), true);\n\n $fields = [];\n foreach ($jsonResponse['recordTypeInfos'] as $row) {\n $fields[] = ['recordTypeId' => $row['recordTypeId'], 'default' => $row['defaultRecordTypeMapping']];\n }\n\n return $fields;\n }\n\n /**\n * Convert raw field data into a format compatible with CRM APIs.\n */\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return ValueNormalizer::normalize($fieldType, $fieldValue, $internal);\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n $defaultFields = ($activityType === Playbook::ACTIVITY_TYPE_TASK)\n ? FieldDefinitions::defaultTaskFields()\n : FieldDefinitions::defaultEventFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n // Setup the activity field as the default Type.\n /** @var Field $activityField */\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'Type',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK, Playbook::ACTIVITY_TYPE_EVENT];\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n\n $fieldFilter = ($activityType === Playbook::ACTIVITY_TYPE_TASK)\n ? FieldDefinitions::taskFollowupFieldsFilter()\n : FieldDefinitions::eventFollowupFieldsFilter();\n\n foreach ($fieldFilter as $eachFilter) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $eachFilter);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n /**\n * This one is now called only when ImportActivityTypes is triggered or SyncFieldMetadata executed manually\n * Regular sync now uses SharedSyncFieldsTrait -> syncSingleObjectType\n * Needs to be replaced later on\n */\n public function syncField(Field $field): void\n {\n try {\n if (FieldHelper::isCustomField($field)) {\n $query = '\n SELECT\n Id, Metadata, TableEnumOrId\n FROM\n CustomField\n WHERE\n DeveloperName = :fieldName\n AND\n TableEnumOrId = :fieldType\n AND\n NamespacePrefix = :namespacePrefix';\n\n // We need to constrain the field lookup to the object, in case it's used in multiple places.\n $objectType = \\in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true)\n ? 'activity'\n : $field->object_type;\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'fieldName' => substr($field->crm_provider_id, 0, -\\strlen('__c')),\n 'fieldType' => ucfirst($objectType),\n\n // This is used to ensure we only consider the field within the org, not installed packages.\n 'namespacePrefix' => 'null',\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n // Sync field metadata.\n $metadata = $sfField['Metadata'];\n\n $field->description = mb_strimwidth($metadata['description'] ?? '', 0, 191);\n $field->label = mb_strimwidth($metadata['label'] ?? '', 0, Field::LABEL_MAX_LENGTH);\n $field->type = $this->convertFieldType($metadata['type'], $field->getEntityName());\n $field->is_mandatory = ($metadata['required'] === true);\n $field->length = $metadata['length'];\n $field->default_value = mb_strimwidth(trim($metadata['defaultValue'] ?? '', '\"'), 0, 191);\n $field->save();\n } else {\n $query = '\n SELECT\n Id, DataType, DeveloperName, Label, Length, Description\n FROM\n FieldDefinition\n WHERE\n DurableId = :entityName';\n\n $entityName = $field->getEntityName();\n $sfFields = $this->queryHandler->metadata($query, [\n 'entityName' => $entityName,\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n $convertedType = $this->convertFieldType($sfField['DataType'], $entityName);\n $label = mb_strimwidth($sfField['Label'], 0, Field::LABEL_MAX_LENGTH);\n\n if ($field->isBusinessType()) {\n $label = 'Opportunity Type';\n }\n\n $field->description = mb_strimwidth($sfField['Description'], 0, Field::DESCRIPTION_MAX_LENGTH);\n $field->label = $label;\n $field->type = $convertedType;\n $field->length = $sfField['Length'];\n $field->save();\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n private function convertFieldType(string $from, ?string $entityName = null): string\n {\n $converter = new FieldTypeConverter();\n\n return $converter->convert($from, $entityName);\n }\n\n /**\n * @inheritdoc\n */\n public function importPicklistValues(Field $field): array\n {\n $values = [];\n $fieldValues = [];\n\n try {\n if (FieldHelper::isCustomField($field)) {\n $query = '\n SELECT\n Id, Metadata, TableEnumOrId\n FROM\n CustomField\n WHERE\n DeveloperName = :fieldName\n AND\n TableEnumOrId = :fieldType\n AND\n NamespacePrefix = :namespacePrefix';\n\n // We need to constrain the field lookup to the object, in case it's used in multiple places.\n $objectType = \\in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true) ?\n 'activity' : $field->object_type;\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'fieldName' => substr($field->crm_provider_id, 0, -\\strlen('__c')),\n 'fieldType' => ucfirst($objectType),\n // This is used to ensure we only consider the field within the org, not installed packages.\n 'namespacePrefix' => 'null',\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n $valueSet = $sfField['Metadata']['valueSet'];\n\n if ($valueSet['valueSetName'] === null) {\n // Local picklist values can be obtained easily.\n $picklistValues = $valueSet['valueSetDefinition']['value'];\n } else {\n // But for some fields, we just get the Global Value Picklist pointer so need to do more work.\n $picklistValues = $this->importGlobalValuePicklistValues($valueSet['valueSetName']);\n }\n\n // Import all active values.\n foreach ($picklistValues as $i => $sfFieldValue) {\n // Setup default value.\n if ($sfFieldValue['default']) {\n $field->update(['default_value' => $sfFieldValue['valueName']]);\n }\n\n // This comes through as null if active (lol).\n if ($sfFieldValue['isActive'] !== false) {\n $values[] = [\n 'value' => $sfFieldValue['valueName'],\n 'label' => $sfFieldValue['valueName'],\n 'sequence' => $i,\n 'is_default' => $sfFieldValue['default'],\n ];\n }\n }\n } else {\n $objectFields = $this->getObjectFields($field->object_type);\n $fieldId = $field->crm_provider_id;\n\n // Only work with our field of interest.\n $objectField = array_filter($objectFields, function ($item) use ($fieldId) {\n return $item['name'] === $fieldId;\n });\n\n $objectField = array_shift($objectField);\n if (empty($objectField['picklistValues']) === false) {\n foreach ($objectField['picklistValues'] as $i => $sfFieldValue) {\n // Skip inactive values.\n if ($sfFieldValue['active'] === false) {\n continue;\n }\n\n // Setup default value.\n if ($sfFieldValue['defaultValue']) {\n $field->update(['default_value' => $sfFieldValue['value']]);\n }\n\n $values[] = [\n 'value' => $sfFieldValue['value'],\n 'label' => $sfFieldValue['label'],\n 'sequence' => $i,\n 'is_default' => $sfFieldValue['defaultValue'],\n ];\n }\n }\n }\n\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n $value['value'] = substr($value['value'] ?? '', 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n if (($key = array_search($value['value'], $fieldsToPurge, true)) !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n // Get IDs of the values to be deleted\n $valuesToDelete = $field->values()->whereIn('value', $fieldsToPurge);\n $valuesToDeleteIds = $valuesToDelete->pluck('id');\n if (! $valuesToDeleteIds->isEmpty()) {\n $recordTypeFieldValuesRepository = app(RecordTypeFieldValuesRepository::class);\n $recordTypeFieldValuesRepository->deleteForCrmFieldValueIds($valuesToDeleteIds->toArray());\n\n // Now safely delete from crm_field_values\n $valuesToDelete->delete();\n }\n\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n return $fieldValues;\n }\n\n /**\n * Gets values from Global Value Picklists.\n */\n private function importGlobalValuePicklistValues(string $picklistName): array\n {\n $query = '\n SELECT\n Metadata\n FROM\n GlobalValueSet\n WHERE\n DeveloperName = :picklistName\n LIMIT 1';\n\n try {\n $sfValues = $this->queryHandler->metadata($query, [\n 'picklistName' => $picklistName,\n ]);\n\n // There is always 1 result at this point.\n $sfValue = $sfValues->current();\n\n return $sfValue['Metadata']['customValue'];\n } catch (NoResultsException $noResultsException) {\n // Nothing returned.\n\n return [];\n }\n }\n\n /**\n * @inheritdoc\n */\n public function syncProfileRecordTypes(): void\n {\n $objectTypes = [\n 'lead',\n 'account',\n 'contact',\n 'opportunity',\n 'task',\n 'event',\n ];\n\n foreach ($objectTypes as $objectType) {\n try {\n $crmRecordTypes = $this->getRecordTypes(ucfirst($objectType));\n\n foreach ($crmRecordTypes as $crmRecordType) {\n // If the record type is default and not the Master type, set this.\n if ($crmRecordType['default'] && $crmRecordType['recordTypeId'] !== '012000000000000AAA') {\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $crmRecordType['recordTypeId'])\n ->first();\n\n if ($recordType) {\n $this->profile->{$objectType . '_record_type_id'} = $recordType->id;\n }\n }\n }\n } catch (HttpNotFoundException $exception) {\n Log::error('No access to ' . $objectType . ' object, skipping...');\n\n // XXX: should we log this fact somewhere?\n continue;\n }\n }\n\n if ($this->profile->isDirty()) {\n $this->profile->save();\n }\n }\n\n /**\n * Gets business processes.\n */\n public function importBusinessProcesses(): void\n {\n $query = '\n SELECT\n Id, IsActive, Name, TableEnumOrId\n FROM\n BusinessProcess\n WHERE\n TableEnumOrId IN (\\'Lead\\',\\'Opportunity\\')';\n\n try {\n $sfProcesses = $this->queryHandler->query($query);\n\n // Upsert all processes for the team.\n foreach ($sfProcesses as $sfProcess) {\n /** @var BusinessProcess $businessProcess */\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $sfProcess['Id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => $sfProcess['Name'],\n 'type' => $sfProcess['TableEnumOrId'] === 'Lead' ? 'lead' : 'opportunity',\n 'is_selectable' => $sfProcess['IsActive'],\n ]);\n\n $this->importBusinessProcessStages($businessProcess);\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n /**\n * Gets business process stages.\n */\n private function importBusinessProcessStages(BusinessProcess $businessProcess): void\n {\n $query = '\n SELECT\n Metadata\n FROM\n BusinessProcess\n WHERE\n Id = :processId';\n\n try {\n $stages = [];\n $sfProcessStages = $this->queryHandler->metadata($query, [\n 'processId' => $businessProcess->crm_provider_id,\n ]);\n\n // There is always 1 result at this point.\n $sfProcessStage = $sfProcessStages->current();\n\n // Upsert all processes for the team.\n foreach ($sfProcessStage['Metadata']['values'] as $sfProcessStage) {\n $sanitizedName = urldecode($sfProcessStage['valueName']); // Must decode: \"%2C\" becomes \",\" etc.\n\n $stage = $businessProcess->crm->stages()\n // This MUST match on label because this API doesn't use API Name.\n ->where('label', $sanitizedName)\n ->where('type', $businessProcess->type)\n ->where('is_selectable', 1)\n ->first();\n\n if ($stage) {\n $stages[] = $stage->id;\n }\n }\n\n $businessProcess->stages()->sync($stages);\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n /**\n * Gets record types.\n */\n public function importRecordTypes(): void\n {\n $query = '\n SELECT\n Id, IsActive, Name, BusinessProcessId, SobjectType\n FROM\n RecordType';\n\n try {\n $sfRecordTypes = $this->queryHandler->query($query);\n\n // Upsert all record types for the process.\n foreach ($sfRecordTypes as $sfRecordType) {\n $businessProcess = null;\n if ($sfRecordType['BusinessProcessId']) {\n $businessProcess = $this->config->businessProcesses()\n ->where('crm_provider_id', $sfRecordType['BusinessProcessId'])\n ->first();\n }\n\n /** @var RecordType $recordType */\n $recordType = $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $sfRecordType['Id'],\n ], [\n 'team_id' => $this->team->id,\n 'type' => mb_strtolower($sfRecordType['SobjectType']),\n 'name' => $sfRecordType['Name'],\n 'is_selectable' => $sfRecordType['IsActive'],\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n $this->importRecordTypeFieldValues($recordType);\n }\n } catch (NoResultsException $noResultsException) {\n // Do nothing.\n }\n }\n\n /**\n * Import record type - field value mappings. This only works for standard fields.\n */\n private function importRecordTypeFieldValues(RecordType $recordType): void\n {\n try {\n $query = '\n SELECT\n Metadata\n FROM\n RecordType\n WHERE\n Id = :recordTypeId';\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'recordTypeId' => $recordType->crm_provider_id,\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n // Sync field metadata.\n $picklists = $sfField['Metadata']['picklistValues'];\n\n foreach ($picklists as $picklist) {\n $field = $this->config->fields()->where([\n 'type' => Field::TYPE_PICKLIST,\n 'object_type' => $recordType->type,\n 'crm_provider_id' => $picklist['picklist'],\n ])->first();\n\n if ($field) {\n $fieldValues = [];\n\n foreach ($picklist['values'] as $value) {\n // Must decode: \"%2C\" becomes \",\" etc.\n $fieldValue = $field->values()\n ->where('value', urldecode($value['valueName']))\n ->first();\n\n if ($fieldValue) {\n $fieldValues[] = $fieldValue->id;\n }\n }\n\n $recordType->fieldValues()->sync($fieldValues);\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n /**\n * @inheritdoc\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $params = [];\n $missingStage = null;\n if ($types === null) {\n $types = [Stage::TYPE_LEAD, Stage::TYPE_OPPORTUNITY];\n }\n\n foreach ($types as $type) {\n if ($type === Stage::TYPE_LEAD) {\n $query = '\n SELECT\n Id, ApiName, MasterLabel, SortOrder\n FROM\n LeadStatus';\n } else {\n $query = '\n SELECT\n Id, ApiName, MasterLabel, IsActive, SortOrder, DefaultProbability\n FROM\n OpportunityStage';\n }\n\n if ($missingStageName) {\n $escapedStageName = ValueNormalizer::replaceQueryWithStringLiterals($missingStageName);\n\n $query .= ' WHERE ApiName = :stageName';\n\n $params = [\n 'stageName' => $escapedStageName,\n ];\n }\n\n try {\n $sfStages = $this->queryHandler->query($query, $params);\n } catch (NoResultsException $exception) {\n $sfStages = [];\n }\n\n $missingStage = null;\n\n // Upsert all stages for the team.\n foreach ($sfStages as $sfStage) {\n $selectable = true;\n if (array_key_exists('IsActive', $sfStage)) {\n $selectable = $sfStage['IsActive'];\n }\n\n $this->restoreAnyTrashedEntity($this->config->stages(), $sfStage['Id']);\n\n $stage = $this->config->stages()->updateOrCreate([\n 'crm_provider_id' => $sfStage['Id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($sfStage['ApiName'], 0, 50),\n 'label' => mb_strimwidth($sfStage['MasterLabel'], 0, 191),\n 'type' => $type,\n 'sequence' => $sfStage['SortOrder'] ?? 0,\n 'is_selectable' => $selectable,\n 'probability' => $sfStage['DefaultProbability'] ?? null,\n ]);\n\n if ($missingStageName && $missingStageName === $sfStage['ApiName']) {\n $missingStage = $stage;\n }\n }\n\n if ($missingStageName && $missingStage === null) {\n // If they requested a stage that still doesn't exist, it must be inactive so lazy create it.\n $missingStage = $this->config->stages()->create([\n 'crm_provider_id' => Uuid::uuid4(),\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($missingStageName, 0, 50),\n 'label' => mb_strimwidth($missingStageName, 0, 191),\n 'type' => $type,\n 'sequence' => 0,\n 'is_selectable' => 0,\n ]);\n }\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int\n {\n $syncCount = 0;\n $fields = $this->getAllFieldsAsArray('lead');\n if (\\in_array('Id', $fields, true) === false) {\n return $syncCount;\n }\n\n $query = '\n SELECT ' . rtrim(implode(',', $fields), ',') . '\n FROM Lead\n WHERE LastModifiedDate > :since\n ORDER BY LastModifiedDate ASC';\n\n try {\n $sfLeads = $this->queryHandler->query($query, [\n 'since' => $since->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($sfLeads as $sfLead) {\n // Only sync if previously imported.\n if ($this->hasLead($sfLead['Id'])) {\n $this->importLead($sfLead);\n $syncCount++;\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::LEAD);\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLead(string $crmId): ?Lead\n {\n $fields = $this->getAllFieldsAsArray('lead');\n\n $sfLead = $this->getRecord('Lead', $crmId, $fields);\n\n return $this->importLead($sfLead);\n }\n\n private function importLead($crmData): ?Lead\n {\n /** @var ?Stage $stage */\n $stage = null;\n if (isset($crmData['Status'])) {\n // Get the current stage.\n $stage = $this->config\n ->stages()\n ->where('name', $crmData['Status'])\n ->where('type', Stage::TYPE_LEAD)\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_LEAD], $crmData['Status']);\n }\n }\n\n // If we have no way of importing this, just return null :(\n if ($stage === null) {\n return null;\n }\n\n $countryCode = $crmData['CountryCode'] ?? null;\n\n // Salesforce allows custom \"countries\" to be created. Disregard these.\n if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {\n $countryCode = null;\n }\n\n // If we have no country code, try to parse it from the country name.\n if ($countryCode === null && empty($crmData['Country']) !== false) {\n $countryCode = $this->convertCountryNameToCode($crmData['Country']);\n }\n\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($crmData['Phone'] ?? '', 0, 25);\n $parsedNumber = parsePhoneNumber($countryCode, $number);\n\n $mobilePhone = null;\n if (empty($crmData['MobilePhone']) === false) {\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($crmData['MobilePhone'], 0, 25);\n $mobilePhone = phone_e164($countryCode, $number);\n }\n\n $convertedDate = null;\n $convertedAccount = null;\n $convertedOpportunity = null;\n $convertedContact = null;\n\n if ($crmData['IsConverted'] == 'true') {\n $convertedDate = $crmData['ConvertedDate'];\n\n if (empty($crmData['ConvertedAccountId']) === false) {\n $convertedAccount = $this->config\n ->accounts()\n ->where('crm_provider_id', $crmData['ConvertedAccountId'])\n ->first();\n\n if ($convertedAccount === null) {\n try {\n $convertedAccount = $this->syncAccount($crmData['ConvertedAccountId']);\n } catch (HttpNotFoundException $exception) {\n // Probably the user has no permissions to access the converted data.\n }\n }\n }\n\n if (empty($crmData['ConvertedOpportunityId']) === false) {\n $convertedOpportunity = $this->config\n ->opportunities()\n ->where('crm_provider_id', $crmData['ConvertedOpportunityId'])\n ->first();\n\n if ($convertedOpportunity === null) {\n try {\n $convertedOpportunity = $this->syncOpportunity($crmData['ConvertedOpportunityId']);\n } catch (HttpNotFoundException $exception) {\n // Probably the user has no permissions to access the converted data.\n }\n }\n }\n\n if (empty($crmData['ConvertedContactId']) === false) {\n $convertedContact = $this->team\n ->crm\n ->contacts()\n ->where('crm_provider_id', $crmData['ConvertedContactId'])\n ->first();\n\n if ($convertedContact === null) {\n try {\n $convertedContact = $this->syncContact($crmData['ConvertedContactId']);\n } catch (HttpNotFoundException $exception) {\n // Probably the user has no permissions to access the converted data.\n }\n }\n }\n }\n\n if (empty($crmData['Company'])) {\n $company = 'Unknown';\n } else {\n $company = mb_strimwidth($crmData['Company'], 0, 191);\n }\n\n $domain = null;\n if (empty($crmData['Website']) === false) {\n $domain = mb_strimwidth($crmData['Website'], 0, 191);\n $domain = StringUtil::resolveDomain($domain);\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $data = [\n 'team_id' => $this->team->id,\n 'user_id' => $profile?->user_id,\n 'owner_id' => $crmData['OwnerId'] ?? '',\n 'company' => $company,\n 'domain' => $domain,\n 'name' => $crmData['Name'] ? mb_strimwidth($crmData['Name'], 0, 191) : '',\n 'title' => $crmData['Title'] ? mb_strimwidth($crmData['Title'], 0, 128) : null,\n 'email' => $crmData['Email'] ? mb_strimwidth($crmData['Email'], 0, 80) : null,\n 'phone' => $parsedNumber['phone'],\n 'ext' => $parsedNumber['ext'] ?? null,\n 'mobile_phone' => $mobilePhone,\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['Id'],\n modelType: Lead::class,\n fileName: $crmData['Id'],\n avatarText: $crmData['Name']\n ),\n 'stage_id' => $stage->id,\n 'record_type_id' => null,\n 'converted_at' => $convertedDate,\n 'converted_account_id' => $convertedAccount->id ?? null,\n 'converted_opportunity_id' => $convertedOpportunity->id ?? null,\n 'converted_contact_id' => $convertedContact->id ?? null,\n 'country_code' => $countryCode,\n 'remotely_created_at' => $createdDate,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->leads(), $crmData['Id']);\n\n /** @var Lead $lead */\n $lead = $this->config->leads()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n if ($lead->wasChanged('converted_at') && $lead->getConvertedAt() !== null) {\n $this->eventDispatcher->dispatch(new LeadConverted($lead));\n }\n\n $this->handleObjectDeletion($lead, $crmData);\n\n if ($lead->trashed()) {\n return null;\n }\n\n return $lead;\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccounts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n $fields = $this->getAllFieldsAsArray('account');\n\n if (\\in_array('Id', $fields, true) === false) {\n return $syncCount;\n }\n\n $query = '\n SELECT ' . rtrim(implode(',', $fields), ',') . '\n FROM Account\n WHERE LastModifiedDate > :since\n ORDER BY LastModifiedDate ASC';\n\n try {\n $sfAccounts = $this->queryHandler->query($query, [\n 'since' => $since->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($sfAccounts as $sfAccount) {\n // Only sync if previously imported.\n if ($this->hasAccount($sfAccount['Id'])) {\n $this->importAccount($sfAccount);\n $syncCount++;\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::ACCOUNT);\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccount(string $crmId): ?Account\n {\n $fields = $this->getAllFieldsAsArray('account');\n if (! in_array('Id', $fields, true)) {\n $this->logger->info('[Salesforce] Sync account cancelled. Fields are not available.', [\n 'crmId' => $crmId,\n 'userId' => $this->profile->getUserId(),\n ]);\n\n return null;\n }\n\n $sfAccount = $this->getRecord('Account', $crmId, $fields);\n\n return $this->importAccount($sfAccount);\n }\n\n private function importAccount($crmData): Account\n {\n $countryCode = $crmData['BillingCountryCode'] ?? $crmData['ShippingCountryCode'] ?? null;\n\n // Salesforce allows custom \"countries\" to be created. Disregard these.\n if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {\n $countryCode = null;\n }\n\n // If we have no country code, try to parse it from the country names.\n if ($countryCode === null && empty($crmData['BillingCountry']) === false) {\n $countryCode = $this->convertCountryNameToCode($crmData['BillingCountry']);\n }\n\n if ($countryCode === null && empty($crmData['ShippingCountry']) === false) {\n $countryCode = $this->convertCountryNameToCode($crmData['ShippingCountry']);\n }\n\n if (empty($crmData['Phone']) === false) {\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($crmData['Phone'], 0, 25);\n $parsedNumber = parsePhoneNumber($countryCode, $number);\n } else {\n $parsedNumber = [];\n }\n\n $industry = null;\n if (empty($crmData['Industry']) === false) {\n $industry = mb_strimwidth($crmData['Industry'], 0, 40);\n }\n\n $domain = null;\n if (empty($crmData['Website']) === false) {\n $domain = mb_strimwidth($crmData['Website'], 0, 191);\n $domain = StringUtil::resolveDomain($domain);\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $data = [\n 'team_id' => $this->team->id,\n 'user_id' => $profile?->user_id,\n 'owner_id' => $crmData['OwnerId'],\n 'name' => mb_strimwidth($crmData['Name'], 0, 191),\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['Id'],\n modelType: Account::class,\n fileName: $crmData['Id'],\n avatarText: $crmData['Name']\n ),\n 'industry' => $industry,\n 'domain' => $domain,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'ext' => $parsedNumber['ext'] ?? null,\n 'country_code' => $countryCode,\n 'remotely_created_at' => $createdDate,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->accounts(), $crmData['Id']);\n\n /** @var Account $account */\n $account = $this->config->accounts()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n $this->handleObjectDeletion($account, $crmData);\n\n return $account;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOpportunities(array $parameters, ?string $strategy = null): int\n {\n $strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);\n\n $syncCount = 0;\n $logParams = $parameters;\n $parameters['profile'] = $this->profile;\n $logParams['user'] = $this->profile->getUserId();\n\n if (count($strategies) > 1) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Multiple sync strategies used', [\n 'teamId' => $this->team->getUuid(),\n 'params' => $logParams,\n 'strategies_count' => count($strategies),\n ]);\n }\n\n foreach ($strategies as $syncStrategy) {\n $name = $syncStrategy->getStrategyName();\n\n try {\n $sfOpportunities = $syncStrategy->fetchOpportunities($parameters);\n $totalRecords = $sfOpportunities->count();\n\n foreach ($sfOpportunities as $sfOpportunity) {\n $this->importOpportunity($sfOpportunity);\n $syncCount++;\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n $this->logger->warning('[' . $this->getDisplayName() . '] No opportunities found', [\n 'teamId' => $this->team->getUuid(),\n 'name' => $name,\n 'params' => $logParams,\n 'reason' => $noResultsException->getMessage(),\n ]);\n } catch (CrmException $crmException) {\n // Nothing to sync.\n $this->logger->warning('[' . $this->getDisplayName() . '] Opportunity sync failed', [\n 'teamId' => $this->team->getUuid(),\n 'name' => $name,\n 'params' => $logParams,\n 'reason' => $crmException->getMessage(),\n ]);\n }\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::OPPORTUNITY, ['params' => $logParams]);\n\n // debug to see how if count of opportunities reaches 1000\n if ($syncCount >= 1000) {\n $this->logger->info(\n '[' . $this->getDisplayName() . '] Sync Opportunities - count warning',\n [\n 'team_id' => $this->team->getId(),\n 'params' => $logParams,\n 'count' => $syncCount,\n 'strategies_count' => count($strategies),\n 'total_records' => $totalRecords ?? null,\n ]\n );\n }\n\n return $syncCount;\n }\n\n\n /**\n * @inheritdoc\n */\n public function syncOpportunity(string $crmId): ?Opportunity\n {\n $strategy = $this->opportunitySyncStrategyResolver->resolve(\n $this->config,\n OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY\n );\n\n $parameters = [\n 'profile' => $this->profile,\n 'crm_id' => $crmId,\n ];\n\n try {\n $sfOpportunity = $strategy->fetchOpportunities($parameters);\n } catch (HttpNotFoundException $e) {\n $this->logger->info('[' . $this->getDisplayName() . '] Opportunity not found', [\n 'teamId' => $this->team->id_string,\n 'crmId' => $crmId,\n ]);\n\n return null;\n } catch (CrmException $crmException) {\n $this->logger->info('[' . $this->getDisplayName() . '] Opportunity sync failed', [\n 'teamId' => $this->team->id_string,\n 'crmId' => $crmId,\n 'exception' => $crmException->getMessage(),\n ]);\n\n return null;\n }\n\n if ($sfOpportunity instanceof ArrayIterator) {\n return $this->importOpportunity($sfOpportunity->getItems());\n }\n\n return $this->importOpportunity($sfOpportunity);\n }\n\n /**\n * @throws HttpNotFoundException\n */\n private function importOpportunity($crmData): ?Opportunity\n {\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $account = null;\n if (empty($crmData['AccountId']) === false) {\n /** @var ?Account $account */\n $account = $this->config->accounts()\n ->where('crm_provider_id', (string) $crmData['AccountId'])\n ->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmData['AccountId']);\n }\n }\n\n $userId = $profile?->getUserId() ?? $account?->getUserId();\n if ($userId === null) {\n $this->logger->error('[Salesforce] | Skip import, no user_id found', [\n 'id' => $crmData['Id'],\n ]);\n\n return null;\n }\n\n /** @var ?Stage $stage */\n $stage = null;\n if (isset($crmData['StageName'])) {\n $stage = $this->config\n ->stages()\n ->where('name', $crmData['StageName'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->orderBy('is_selectable', 'DESC')\n ->orderBy('id')\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $crmData['StageName']);\n }\n }\n\n $recordType = null;\n if (empty($crmData['RecordTypeId']) === false) {\n /** @var ?RecordType $recordType */\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $crmData['RecordTypeId'])\n ->first();\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $closeDate = null;\n if (empty($crmData['CloseDate']) === false) {\n $closeDate = Carbon::parse($crmData['CloseDate'])->format('Y-m-d');\n }\n\n $valueFieldName = 'Amount';\n if ($this->config->opportunity_value_field_id) {\n $valueFieldName = $this->config->opportunityValueField->crm_provider_id;\n }\n\n $data = [\n 'team_id' => $this->team->id,\n 'account_id' => $account->id ?? null,\n 'user_id' => $userId,\n 'owner_id' => $crmData['OwnerId'] ?? null,\n 'name' => mb_strimwidth($crmData['Name'] ?? '', 0, 128),\n 'value' => $crmData[$valueFieldName],\n 'currency_code' => CurrencyFormatter::formatCode($crmData['CurrencyIsoCode'] ?? null),\n 'close_date' => $closeDate,\n 'is_closed' => $crmData['IsClosed'],\n 'is_won' => $crmData['IsWon'],\n 'stage_id' => $stage?->id ?? null,\n 'record_type_id' => $recordType->id ?? null,\n 'remotely_created_at' => $createdDate,\n 'probability' => $crmData['Probability'] ?? null,\n 'forecast_category' => $crmData['ForecastCategoryName'] ?? null,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->opportunities(), $crmData['Id']);\n\n // Do not allow locked DB tables & other errors\n // to interrupt the process of reverting the trashed opportunities\n try {\n /** @var Opportunity $opportunity */\n $opportunity = $this->config->opportunities()\n ->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n // import external fields into crm_field_data if present\n $crmFields = $this->getOpportunitySyncableFields();\n\n $this->importOpportunityCrmFieldData($crmData, $crmFields, $opportunity->id);\n\n $this->handleObjectDeletion($opportunity, $crmData);\n\n if ($opportunity->trashed()) {\n return null;\n }\n\n if ($opportunity->wasRecentlyCreated) {\n MatchActivitiesToNewOpportunity::dispatch($opportunity->getId());\n }\n\n return $opportunity;\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n $this->logger->error('[Salesforce] importOpportunity failure.', [\n 'crm_provider_id' => $crmData['Id'],\n 'team_id' => $this->team->id,\n 'exception' => $exception->getMessage(),\n ]);\n\n $this->handleEntityDeletionByProviderId($this->config->opportunities(), $crmData);\n }\n\n return null;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContacts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n $fields = $this->getAllFieldsAsArray('contact');\n if (\\in_array('Id', $fields, true) === false) {\n return $syncCount;\n }\n\n $query = '\n SELECT ' . rtrim(implode(',', $fields), ',') . '\n FROM Contact\n WHERE LastModifiedDate > :since\n ORDER BY LastModifiedDate ASC';\n\n try {\n $sfContacts = $this->queryHandler->query($query, [\n 'since' => $since->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($sfContacts as $sfContact) {\n // Only sync if previously imported.\n if ($this->hasContact($sfContact['Id'])) {\n $this->importContact($sfContact);\n $syncCount++;\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::CONTACT);\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContact(string $crmId): ?Contact\n {\n $fields = $this->getAllFieldsAsArray('contact');\n if (! in_array('Id', $fields, true)) {\n $this->logger->info('[Salesforce] Sync contact cancelled. Fields are not available.', [\n 'crmId' => $crmId,\n 'userId' => $this->profile->getUserId(),\n ]);\n\n return null;\n }\n\n $sfContact = $this->getRecord('Contact', $crmId, $fields);\n\n return $this->importContact($sfContact);\n }\n\n private function importContact($crmData): ?Contact\n {\n $account = null;\n // Contacts may not have accounts...\n if (isset($crmData['AccountId'])) {\n $account = $this->config->accounts()\n ->where('crm_provider_id', (string) $crmData['AccountId'])\n ->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmData['AccountId']);\n }\n }\n\n $countryCode = $crmData['MailingCountryCode'] ?? null;\n\n // Salesforce allows custom \"countries\" to be created. Disregard these.\n if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {\n $countryCode = null;\n }\n\n // If we have no country code, try to parse it from the country name.\n if ($countryCode === null && empty($crmData['MailingCountry']) === false) {\n $countryCode = $this->convertCountryNameToCode($crmData['MailingCountry']);\n\n if ($countryCode === null && $account) {\n $countryCode = $account->country_code;\n }\n }\n\n $ext = null;\n $parsedNumber = [];\n if (empty($crmData['Phone']) === false) {\n $number = Str::limit($crmData['Phone'], 25, '');\n $parsedNumber = parsePhoneNumber($countryCode, $number);\n\n if (empty($parsedNumber['ext']) === false) {\n $ext = Str::limit($parsedNumber['ext'], 10, '');\n }\n }\n\n $mobileNumber = null;\n if (empty($crmData['MobilePhone']) === false) {\n $mobileNumber = Str::limit(phone_e164($countryCode, $crmData['MobilePhone']), 25, '');\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $data = [\n 'team_id' => $this->team->id,\n 'account_id' => $account->id ?? null,\n 'user_id' => $profile?->user_id,\n 'owner_id' => $crmData['OwnerId'] ?? null,\n 'name' => ($crmData['Name'] ?? null) !== null ? mb_strimwidth($crmData['Name'], 0, 100) : '',\n 'title' => ($crmData['Title'] ?? null) !== null ? mb_strimwidth($crmData['Title'], 0, 128) : null,\n 'email' => ($crmData['Email'] ?? null) !== null ? mb_strimwidth($crmData['Email'], 0, 191) : null,\n 'country_code' => $countryCode,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'ext' => $ext,\n 'mobile_phone' => $mobileNumber,\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['Id'],\n modelType: Contact::class,\n fileName: $crmData['Id'],\n avatarText: $crmData['Name']\n ),\n 'remotely_created_at' => $createdDate,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->contacts(), $crmData['Id']);\n\n /** @var Contact $contact */\n $contact = $this->config->contacts()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n $this->handleObjectDeletion($contact, $crmData);\n\n if ($contact->trashed()) {\n return null;\n }\n\n return $contact;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n $fields = [\n 'InstanceName',\n 'OrganizationType',\n 'IsSandbox',\n ];\n\n $orgValues = $this->getRecord('Organization', $this->config->crm_provider_id, $fields);\n\n $edition = null;\n switch ($orgValues['OrganizationType']) {\n case 'Developer Edition':\n $edition = Configuration::EDITION_DEVELOPER;\n\n break;\n\n case 'Professional Edition':\n $edition = Configuration::EDITION_PROFESSIONAL;\n\n break;\n\n case 'Enterprise Edition':\n $edition = Configuration::EDITION_ENTERPRISE;\n\n break;\n }\n\n $this->config->edition = $edition;\n $this->config->instance = $orgValues['InstanceName'];\n\n // XXX: How can this state be possible?\n if ($this->config->version === null) {\n $this->config->version = Client::MIN_API_VERSION;\n }\n\n $installedVersion = $this->getInstalledAppVersion();\n if ($installedVersion !== null) {\n $installedVersion = (string) $this->getInstalledAppVersion();\n }\n\n $this->config->installed_app_version = $installedVersion;\n\n $this->config->save();\n }\n\n public function getInstalledAppVersion(): ?string\n {\n try {\n $query = '\n SELECT\n SubscriberPackageVersion.MajorVersion,\n SubscriberPackageVersion.MinorVersion,\n SubscriberPackageVersion.PatchVersion,\n SubscriberPackageVersion.BuildNumber\n FROM\n InstalledSubscriberPackage\n WHERE\n SubscriberPackageId = :packageId\n ';\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'packageId' => self::INSTALLED_PACKAGE_ID,\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n // Grab version number.\n $version = $sfField['SubscriberPackageVersion']['MajorVersion'] .\n $sfField['SubscriberPackageVersion']['MinorVersion'] .\n $sfField['SubscriberPackageVersion']['PatchVersion'] .\n $sfField['SubscriberPackageVersion']['BuildNumber'];\n } catch (\\Exception) {\n $version = null;\n }\n\n return $version;\n }\n\n /**\n * Store transcripts as note.\n *\n * @throws \\Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // For SF we also check if Log Notes is enabled.\n if ($this->profile->log_notes === Profile::LOG_NOTE_NONE) {\n return;\n }\n\n if ($activity->opportunity_id && $activity->prospect === null) {\n return;\n }\n\n try {\n $transcriptionData = $this->generateTranscription($activity);\n\n $noteMaxLength = $this->profile->log_notes === Profile::LOG_NOTE_ENHANCED\n ? self::ENHANCED_NOTE_MAX_LENGTH\n : self::CLASSIC_NOTE_MAX_LENGTH;\n\n $title = 'Transcript for ';\n $title .= $activity->title ?? $activity->activity_title;\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $body = mb_strimwidth($transcriptionData, 0, $noteMaxLength);\n\n if ($activity->opportunity_id) {\n $objectId = $activity->opportunity->crm_provider_id;\n } else {\n $objectId = $activity->prospect->crm_provider_id;\n }\n\n $noteId = $this->saveNote($title, $body, $objectId);\n\n // Store crm logged id in transcription.\n $transcription = $activity->getTranscription();\n $transcription->crm_activity_id = $noteId;\n $transcription->save();\n } catch (\\Exception $e) {\n \\Sentry::captureException($e);\n }\n }\n\n public function saveNote(string $title, string $body, string $objectId, ?NoteObject $noteObject = null): ?string\n {\n $noteId = null;\n\n try {\n if ($this->profile->log_notes === Profile::LOG_NOTE_ENHANCED) {\n $noteId = $this->buildEnhancedNote($title, $body, $objectId);\n } else {\n $noteId = $this->buildClassicNote($title, $body, $objectId);\n }\n } catch (HttpNotFoundException $exception) {\n // The profile not having access to create Enhanced Notes. Set their preference to Classic.\n if ($this->profile->log_notes === Profile::LOG_NOTE_ENHANCED) {\n $this->profile->update([\n 'log_notes' => Profile::LOG_NOTE_CLASSIC,\n ]);\n }\n }\n\n return $noteId;\n }\n\n /**\n * This is using the \"Enhanced\" Notes feature, NOT the \"Notes & Attachments\" feature being deprecated.\n *\n * @url https://salesforce.stackexchange.com/questions/104408/how-can-i-create-an-account-note-or-contact-note-via-api-that-is-visible-in-sale\n */\n private function buildEnhancedNote(string $title, string $body, string $objectId): string\n {\n // Decode stored entities, escape HTML (without quoting), then convert line breaks for Salesforce formatting\n $decodedBody = html_entity_decode($body, ENT_QUOTES | ENT_HTML5);\n $sanitizedBody = htmlspecialchars($decodedBody, ENT_NOQUOTES, 'UTF-8', false);\n $content = nl2br($sanitizedBody, false);\n $note = [\n 'OwnerId' => $this->profile->crm_provider_id,\n 'Title' => $title,\n 'Content' => base64_encode($content),\n ];\n\n $noteId = $this->createRecord('ContentNote', $note);\n\n $link = [\n 'ContentDocumentId' => $noteId,\n 'LinkedEntityId' => $objectId,\n 'ShareType' => 'I',\n ];\n\n $this->createRecord('ContentDocumentLink', $link);\n\n return $noteId;\n }\n\n private function buildClassicNote(string $title, string $body, string $objectId): string\n {\n if (in_array($this->parseObjectType($objectId), [Field::OBJECT_TASK, Field::OBJECT_EVENT])) {\n $this->logger->info('[Salesforce] Summary not sent', [\n 'profile_id' => $this->profile->id,\n 'objectId' => $objectId,\n 'reason' => 'Classical Note does not support Task/Event relation',\n ]);\n\n return '';\n }\n\n $titleTrimmed = null;\n\n if (mb_strlen($title) > 80) {\n $titleTrimmed = substr($title, 0, 77) . '...';\n }\n $payload = [\n 'OwnerId' => $this->profile->crm_provider_id,\n 'IsPrivate' => false,\n 'Title' => $titleTrimmed ?? $title,\n 'Body' => $titleTrimmed ? $title . PHP_EOL . $body : $body,\n 'ParentId' => $objectId,\n ];\n\n return $this->createRecord('Note', $payload);\n }\n\n /**\n * @inheritdoc\n */\n public function find(string $name, array $scopes): array\n {\n if ($this->profile === null) {\n return [];\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $limitValues = ['limit' => $this->limit, 'offset' => $this->offset];\n $sosl = $queryBuilder->buildFindQuery($name, $scopes, $limitValues);\n\n $this->logger->info('[Salesforce] Find prospects', [\n 'profile_id' => $this->profile->id,\n 'sosl_query' => $sosl,\n 'search_string' => $name,\n 'scopes' => $scopes,\n ]);\n\n $data = Cache::remember($this->profile->id . $sosl, self::CACHE_TTL, function () use ($sosl) {\n $data = [];\n\n try {\n // Hit remote API.\n $objects = $this->queryHandler->search($sosl);\n\n // Build mapped list.\n foreach ($objects as $object) {\n $type = strtolower($object['attributes']['type']);\n\n $record = [\n 'crmId' => $object['Id'],\n 'name' => $object['Name'],\n 'prospectType' => $type,\n 'phoneNumbers' => [],\n 'crmUrl' => $this->generateProviderUrl($object['Id'], $type),\n ];\n\n switch ($type) {\n case 'lead':\n if (empty($object['Company']) === false) {\n $record['organization'] = $object['Company'];\n }\n\n if (empty($object['Title']) === false) {\n $record['title'] = $object['Title'];\n }\n\n $stage = $this->config->stages()\n ->where('type', Stage::TYPE_LEAD)\n ->where('name', $object['Status'])\n ->first();\n\n // Lazy create the stage.\n if ($stage === null) {\n $stage = $this->importStages([Stage::TYPE_LEAD], $object['Status']);\n }\n\n if ($stage) {\n $record += [\n 'stage' => [\n 'id' => $stage->id_string,\n 'name' => $stage->name,\n ],\n ];\n }\n\n if (empty($object['RecordTypeId']) === false) {\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $object['RecordTypeId'])\n ->first();\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n }\n\n break;\n\n case 'account':\n if (empty($object['Industry']) === false) {\n $record['industry'] = $object['Industry'];\n $record['detailsLine'] = $object['Industry'];\n }\n if (! empty($object['PersonEmail'])) {\n $record['detailsLine'] = $object['PersonEmail'];\n }\n\n break;\n\n case 'contact':\n // For contacts, we should try and fetch their account name too.\n if ($object['AccountId']) {\n // Cheaper to get this locally.\n $account = $this->config->accounts()\n ->where('crm_provider_id', $object['AccountId'])\n ->first(['name']);\n\n if ($account) {\n $record['organization'] = $account->name;\n }\n }\n\n if (! empty($object['IsPersonAccount']) && $object['Email']) {\n $record['detailsLine'] = $object['Email'];\n } else {\n if (empty($object['Title']) === false) {\n $record['title'] = $object['Title'];\n }\n }\n\n break;\n }\n\n // Add phone numbers to record.\n if (empty($object['Phone']) === false && $object['Phone']) {\n $record['phoneNumbers'][] = [\n 'number' => $object['Phone'],\n 'nationalFormat' => phone_national($this->profile->user->country_code, $object['Phone']),\n 'type' => 'phone',\n ];\n }\n\n if (empty($object['MobilePhone']) === false && $object['MobilePhone']) {\n $record['phoneNumbers'][] = [\n 'number' => $object['MobilePhone'],\n 'nationalFormat' => phone_national(\n $this->profile->user->country_code,\n $object['MobilePhone']\n ),\n 'type' => 'mobile',\n ];\n }\n\n $data[] = $record;\n }\n } catch (NoResultsException $e) {\n $data = [];\n }\n\n return $data;\n });\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n\n if ($crmAccountId === null) {\n return $data;\n }\n\n if ($userId) {\n $profileRepository = app(ProfileRepository::class);\n $profile = $profileRepository->findProfileByUserId($this->config, $userId);\n\n $ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;\n }\n\n try {\n // Perhaps their profile has no opportunity permissions.\n if ($this->profile === null || $this->profile->opportunity_fields === null) {\n return $data;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $query = $queryBuilder->buildFindOpportunitiesQuery();\n\n $objects = $this->queryHandler->query($query, ['accountId' => $crmAccountId]);\n\n foreach ($objects as $object) {\n $record = [\n 'crmId' => $object['Id'],\n 'name' => $object['Name'],\n 'won' => $object['IsWon'],\n 'closed' => $object['IsClosed'],\n ];\n\n $valueFieldName = 'Amount';\n if ($this->config->opportunity_value_field_id) {\n $valueFieldName = $this->config->opportunityValueField->crm_provider_id;\n }\n\n if (empty($object[$valueFieldName]) === false) {\n $currency = $object['CurrencyIsoCode'] ?? $this->config->default_currency;\n $value = formatCurrency($object[$valueFieldName], $currency);\n\n $record += [\n 'value' => $value,\n ];\n }\n\n $stage = $this->config->stages()\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->where('name', $object['StageName'])\n ->first();\n\n // Lazy create the stage.\n if ($stage === null) {\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $object['StageName']);\n }\n\n $record += [\n 'stage' => [\n 'id' => $stage->id_string,\n 'name' => $stage->name,\n ],\n ];\n\n if (empty($object['RecordTypeId']) === false) {\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $object['RecordTypeId'])\n ->first();\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n }\n\n if ($ownerId && isset($object['OwnerId']) && $object['OwnerId'] === $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n } catch (NoResultsException $e) {\n return $data;\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n public function getContactRolesFromCrm(?Carbon $since = null): array\n {\n $roles = [];\n\n if ($this->profile === null) {\n return $roles;\n }\n\n $queryBuilder = app(QueryBuilder::class, ['profile' => $this->profile]);\n\n $query = $queryBuilder->buildGetContactRolesQuery($since);\n\n try {\n $objects = $this->queryHandler->query($query);\n\n foreach ($objects as $object) {\n $roles[] = [\n 'id' => $object['Id'],\n 'contactId' => $object['ContactId'],\n 'opportunityId' => $object['OpportunityId'],\n 'ownerId' => $object['Opportunity']['OwnerId'] ?? null,\n 'isPrimary' => $object['IsPrimary'],\n 'role' => $object['Role'],\n ];\n }\n } catch (NoResultsException) {\n // Just return an empty array.\n $this->logger->info('[Salesforce] No contact roles found', [\n 'since' => $since?->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n }\n\n return $roles;\n }\n\n public function syncContactRoles(Carbon $since): int\n {\n $contactRoleRepository = app(ContactRoleRepository::class);\n\n $crmContactRoles = $this->getContactRolesFromCrm(since: $since);\n $syncCount = 0;\n $contactRoles = [];\n\n foreach ($crmContactRoles as $crmContactRole) {\n $contactRoles[] = $this->importContactRole($crmContactRole);\n $syncCount++;\n }\n\n $contactRoleRepository->saveContactRoles($contactRoles);\n\n $this->syncRemotelyDeletedContactRoles();\n\n return $syncCount;\n }\n\n private function importContactRole(array $contactRole): array\n {\n $contact = $this->config->contacts()\n ->where('crm_provider_id', $contactRole['contactId'])\n ->first();\n\n if ($contact === null) {\n $contact = $this->syncContact($contactRole['contactId']);\n }\n\n $opportunity = $this->config->opportunities()\n ->where('crm_provider_id', $contactRole['opportunityId'])\n ->first();\n\n if ($opportunity === null) {\n $opportunity = $this->syncOpportunity($contactRole['opportunityId']);\n }\n\n $role = null;\n if (! empty($contactRole['role'])) {\n $role = mb_strimwidth($contactRole['role'], 0, 191);\n }\n\n return [\n 'crm_configuration_id' => $this->config->getId(),\n 'contact_id' => $contact->getId(),\n 'crm_provider_id' => $contactRole['id'],\n 'subject_type' => ContactRole::SUBJECT_TYPE_OPPORTUNITY,\n 'subject_id' => $opportunity->getId(),\n 'is_primary' => $contactRole['isPrimary'],\n 'role' => $role,\n ];\n }\n\n protected function syncRemotelyDeletedContactRoles(): bool\n {\n try {\n $deletedRemotely = $this->queryHandler->queryDeleted('OpportunityContactRole');\n } catch (NoResultsException $e) {\n return false;\n }\n\n $deletedOpportunities = $deletedRemotely->getResults();\n $deletedIds = array_column($deletedOpportunities, 'id');\n\n $contactRoleRepository = app(ContactRoleRepository::class);\n\n foreach (array_chunk($deletedIds, self::HARD_DELETE_CHUNK) as $chunk) {\n $contactRoleRepository->deleteContactRoles($chunk);\n\n $this->logger->info('[' . $this->getDisplayName() . '] Remotely deleted opportunities synced', [\n 'teamId' => $this->team->id_string,\n 'remotelyDeletedOpportunities' => $chunk,\n 'count' => count($chunk),\n ]);\n }\n\n return true;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = $objects = [];\n\n $hasWho = \\in_array($objectType, ['lead', 'contact']);\n $playbook = $this->getPlaybook($this->profile->user);\n $fields = array_merge(\n $this->profile->getFieldsAsArray(parent::OBJECT_TASK),\n $playbook && $playbook->activityField ? [$playbook->activityField->crm_provider_id] : []\n );\n\n // Query should default to any open call for that user.\n $query = '\n SELECT ' . implode(',', array_unique($fields)) . '\n FROM Task\n WHERE OwnerId = :ownerId\n AND IsArchived = false\n AND IsDeleted = false\n AND IsClosed = false\n AND (';\n\n if ($objectType === 'account') {\n // This covers tasks tied to a related contact or opportunity too.\n $query .= '\n AccountId = :accountId';\n }\n\n if ($hasWho) {\n $query .= '\n WhoId = :whoId';\n\n // If we are also going to check on a specific opportunity, set that up.\n if ($opportunityId) {\n $query .= ' OR WhatId = :whatId';\n }\n }\n\n $query .= ' ) ORDER BY LastModifiedDate DESC';\n\n try {\n $objects = $this->queryHandler->query($query, [\n 'ownerId' => $this->profile->crm_provider_id,\n 'whoId' => $objectId,\n 'whatId' => $opportunityId,\n 'accountId' => $objectId,\n ]);\n } catch (NoResultsException $e) {\n return $data;\n } finally {\n $this->logger->debug(sprintf('[Salesforce] Found %s tasks for query \"%s\"', count($objects), $query));\n }\n\n foreach ($objects as $object) {\n $dueDate = $object['ActivityDate'] ? Carbon::parse($object['ActivityDate'])->toIso8601String() : null;\n $data[] = [\n 'crmId' => $object['Id'],\n 'subject' => $object['Subject'],\n 'due' => $dueDate,\n 'type' => $object[$playbook->activityField->crm_provider_id],\n ];\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getEvents(string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = $objects = [];\n $user = $this->profile?->user;\n if ($this->profile === null || $user === null) {\n return $data;\n }\n\n $hasWho = \\in_array($objectType, ['lead', 'contact']);\n $playbook = $this->getPlaybook($user);\n $fields = array_merge(\n $this->profile->getFieldsAsArray(parent::OBJECT_EVENT),\n $playbook && $playbook->activityField ? [$playbook->activityField->crm_provider_id] : []\n );\n\n // Query should default to any event starting in the last week and ending up until today owned by the user.\n $query = '\n SELECT ' . implode(',', array_unique($fields)) . '\n FROM Event\n WHERE OwnerId = :ownerId\n AND IsArchived = false\n AND IsAllDayEvent = false\n AND StartDateTime >= LAST_N_DAYS:7\n AND EndDateTime <= TODAY\n AND (';\n\n if ($objectType === 'account') {\n // This covers events tied to a related contact or opportunity too.\n $query .= '\n AccountId = :accountId';\n }\n\n if ($hasWho) {\n $query .= '\n WhoId = :whoId';\n\n // If we are also going to check on a specific opportunity, set that up.\n if ($opportunityId) {\n $query .= ' OR WhatId = :whatId';\n }\n }\n\n $query .= ' ) ORDER BY LastModifiedDate DESC';\n\n try {\n $objects = $this->queryHandler->query($query, [\n 'ownerId' => $this->profile->crm_provider_id,\n 'whoId' => $objectId,\n 'whatId' => $opportunityId,\n 'accountId' => $objectId,\n ]);\n } catch (NoResultsException $e) {\n return $data;\n } finally {\n $this->logger->debug(sprintf('[Salesforce] Found %s tasks for query \"%s\"', count($objects), $query));\n }\n\n foreach ($objects as $object) {\n $dueDate = $object['StartDateTime'] ? Carbon::parse($object['StartDateTime'])->toIso8601String() : null;\n\n $data[] = [\n 'crmId' => $object['Id'],\n 'subject' => $object['Subject'],\n 'due' => $dueDate,\n 'type' => $object[$playbook->activityField->crm_provider_id],\n ];\n }\n\n return $data;\n }\n\n /**\n * Try to find CRM Objects using email address\n *\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchExactlyByEmail(string $email, ?int $userId = null): ?array\n {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $sosl = $queryBuilder->buildMatchByQuery($email, Field::TYPE_EMAIL);\n if ($sosl === null) {\n return null;\n }\n\n try {\n $objects = $this->queryHandler->search($sosl);\n $objects = $this->queryHandler->prioritiseResults(\n $objects,\n $email,\n QueryHandler::PRIORITISE_EMAIL\n );\n\n $data = $this->convertCrmData($objects, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (NoResultsException $e) {\n // Try the account next.\n if ($this->profile->account_fields === null) {\n return null;\n }\n }\n\n return null;\n }\n\n public function getDomain(string $email): ?string\n {\n // SF improved search - strip the domain extension, min domain name length 4\n return $this->getCompanyNameFromEmail(email: $email, minNameLength: 4);\n }\n\n /**\n * Try to find CRM objects using domain name of the email address\n *\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByDomain(string $domain, ?int $userId = null): ?array\n {\n $companyName = $domain;\n\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $sosl = $queryBuilder->buildMatchByDomainQuery($companyName);\n\n try {\n $objects = $this->queryHandler->search($sosl);\n\n $data = $this->convertCrmData($objects, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (NoResultsException) {\n return null;\n }\n }\n\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n // Don't bother looking up numbers that are masked.\n if (str_contains($phone, '**')) {\n return null;\n }\n\n if ($this->isPhoneNumberOfTeamMember($phone)) {\n return null;\n }\n\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $phoneNational = phone_national(null, $phone) ?? '';\n $possiblePhoneFormats = collect([\n preg_replace('/\\D/', '', ltrim($phone, '0+')),\n preg_replace('/\\D/', '', $phoneNational),\n formatDashPhoneNumber($phone),\n $phoneNational,\n ])\n ->filter() // Removes null and empty strings\n ->unique()\n ->values();\n\n foreach ($possiblePhoneFormats as $phone) {\n $sosl = $queryBuilder->buildMatchByQuery($phone, Field::TYPE_PHONE);\n if ($sosl === null) {\n continue;\n }\n\n try {\n $objects = $this->queryHandler->search($sosl);\n $objects = $this->queryHandler->prioritiseResults(\n $objects,\n $phone,\n QueryHandler::PRIORITISE_PHONE\n );\n\n return $this->convertCrmData($objects, $userId);\n } catch (NoResultsException) {\n continue;\n }\n }\n\n return null;\n }\n\n private function isPhoneNumberOfTeamMember(string $phone): bool\n {\n $teamRepository = app(TeamRepository::class);\n $user = $teamRepository->findTeamMemberByPhone($this->team, $phone);\n\n if ($user instanceof User) {\n return true;\n }\n\n return false;\n }\n\n protected function getCacheKey(string $object, ?int $userId = null): ?string\n {\n $key = $this->profile->id . $object;\n $keySuffix = $this->getOwnerKeySuffix($userId);\n\n return $key . $keySuffix;\n }\n\n private function getOwnerKeySuffix(?int $userId = null): string\n {\n return $userId === null ? '' : (string) $userId;\n }\n\n /** Determine the CRM Objects which represent the call activity. */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n if ($this->profile === null) {\n return null;\n }\n\n $cacheKey = $this->getCacheKey($name, $userId);\n\n $result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $sosl = $queryBuilder->buildMatchByQuery($name, 'name');\n if ($sosl === null) {\n return false;\n }\n\n try {\n $objects = $this->queryHandler->search($sosl);\n } catch (NoResultsException $e) {\n return false;\n }\n\n $objects = $this->queryHandler->prioritiseResults(\n $objects,\n $name,\n QueryHandler::PRIORITISE_NAME\n );\n\n $data = $this->convertCrmData($objects, $userId);\n\n return (! empty(array_filter($data))) ? $data : false;\n });\n\n return is_array($result) ? $result : null;\n }\n\n /**\n * @return array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n protected function convertCrmData(QueryIterator $objects, ?int $userId = null): array\n {\n $lead = null;\n $contact = null;\n $opportunity = null;\n $account = null;\n $stage = null;\n $countryCode = null;\n\n if ($objects->count() > 0) {\n $object = $objects->current();\n\n if ($object['attributes']['type'] === 'Lead') {\n $lead = $this->importLead($object);\n\n // Lead might not be imported if the Stage is null for example.\n if ($lead) {\n $countryCode = $lead->country_code;\n $stage = $lead->stage;\n }\n } else {\n if ($object['attributes']['type'] === 'Contact') {\n $contact = $this->importContact($object);\n $account = $contact->account;\n } else {\n $account = $this->importAccount($object);\n }\n\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account) {\n $countryCode = $account->country_code;\n }\n\n try {\n $sfOpportunities = $this->findOpportunities(\n $account?->getCrmProviderId(),\n $contact?->getCrmProviderId(),\n $userId\n );\n\n // Take the first opportunity, which will be ordered as priority based on their settings.\n if (! empty($sfOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($sfOpportunities[0]['crmId']);\n $stage = $opportunity?->stage;\n }\n } catch (Exception) {\n // Nothing to see here.\n }\n }\n }\n\n return [\n $lead,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n /**\n * @inheritdoc\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n if ($stage->type === Stage::TYPE_LEAD) {\n $objectType = 'Lead';\n $objectId = $crmObject->crm_provider_id;\n $objectStageType = 'Status';\n } else {\n $objectType = 'Opportunity';\n $objectId = $crmObject->crm_provider_id;\n $objectStageType = 'StageName';\n }\n\n $headers = [];\n if ($this->config->trigger_assignment_rules === false) {\n // @see: https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/headers_autoassign.htm\n $headers = [\n 'Sforce-Auto-Assign' => 'false',\n ];\n }\n\n $this->updateRecord($objectType, $objectId, [$objectStageType => $stage->name], $headers);\n }\n\n public function parseObjectType(string $objectId): string\n {\n if (Str::startsWith($objectId, '001')) {\n return 'account';\n }\n\n if (Str::startsWith($objectId, '003')) {\n return 'contact';\n }\n\n if (Str::startsWith($objectId, '00Q')) {\n return 'lead';\n }\n\n if (Str::startsWith($objectId, '006')) {\n return 'opportunity';\n }\n\n if (Str::startsWith($objectId, '00U')) {\n return 'event';\n }\n\n if (Str::startsWith($objectId, '00T')) {\n return 'task';\n }\n\n throw new \\InvalidArgumentException('Unsupported Object Type');\n }\n\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, ['profile' => $this->profile]);\n $query = $queryBuilder->buildGetUsersQuery($userToSearch);\n\n try {\n $salesforceUsers = $this->queryHandler->query($query, [\n 'active' => true,\n ]);\n } catch (NoResultsException $e) {\n $this->logger->info('[Salesforce] Sync Profiles. No users found', [\n 'query' => $query,\n 'error' => $e->getMessage(),\n ]);\n\n return null;\n }\n\n $teamRepository = app(TeamRepository::class);\n $customRules = $this->getCustomProfileRules($teamRepository);\n\n foreach ($salesforceUsers as $crmUser) {\n if ($crmUser['Email'] === null) {\n continue;\n }\n\n if (! $this->customProfileValidation($crmUser, $customRules)) {\n continue;\n }\n\n $user = $teamRepository->findActiveTeamMemberByEmail($this->team, $crmUser['Email']);\n\n if (! $user instanceof User) {\n continue;\n }\n\n $edition = $crmUser['UserPreferencesLightningExperiencePreferred']\n ? Profile::EDITION_LIGHTNING\n : Profile::EDITION_CLASSIC;\n\n $profileRepository = app(ProfileRepository::class);\n $profile = $profileRepository->updateOrCreateProfile(\n $user,\n [\n 'crm_configuration_id' => $this->config->getId(),\n 'crm_provider_id' => $crmUser['Id'],\n ],\n [\n 'user_id' => $user->getId(),\n 'edition' => $edition,\n 'has_external_cti' => ! empty($crmUser['CallCenterId']),\n 'crm_profile_id' => $crmUser['ProfileId'],\n ]\n );\n\n if ($userToSearch instanceof User && $userToSearch->getId() === $user->getId()) {\n return $profile;\n }\n }\n\n // Clean up inactive profiles\n try {\n $this->archiveInactiveProfiles();\n } catch (\\Exception $e) {\n $this->logger->warning('[Salesforce] Profile archiving failed', [\n 'teamId' => $this->team->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $url = null;\n\n // For Salesforce it's easy, we just point every object to the apex domain and they handle it.\n switch ($objectType) {\n case 'lead':\n case 'account':\n case 'contact':\n case 'opportunity':\n case 'task':\n case 'event':\n case 'activity':\n\n $url = $this->config->crm_base_url . '/' . $providerId;\n\n break;\n }\n\n return $url;\n }\n\n public function buildTaskSearchFields(): array\n {\n return ['Id', 'WhoId', 'WhatId', 'AccountId'];\n }\n\n public function getTaskByFilterConditions(\n array $fields,\n array $filters,\n bool $bulkSearch = false,\n bool $strictFilters = true\n ): ?array {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $query = $queryBuilder->buildSearchTaskQuery($fields, $filters, $bulkSearch, $strictFilters);\n\n try {\n if (! $bulkSearch) {\n $objects = $this->queryHandler->query($query, $filters);\n if ($objects->count() === 1) {\n return $objects->current();\n }\n }\n\n if ($bulkSearch) {\n $objects = $this->queryHandler->query($query);\n $records = [];\n foreach ($objects as $record) {\n $key = $record[end($fields)];\n $records[$key] = $record;\n }\n\n return $records;\n }\n } catch (\\Exception $e) {\n $this->logger->info('[Salesforce] Failed to execute query', [\n 'query' => $query,\n 'error' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function mapCrmObjects(array $task): array\n {\n $activityData = [];\n\n if (! empty($task['WhoId'])) {\n $type = $this->parseObjectType($task['WhoId']);\n $activityData[$type] = $task['WhoId'];\n }\n if (! empty($task['AccountId'])) {\n $activityData['account'] = $task['AccountId'];\n }\n if (! empty($task['WhatId'])) {\n $activityData['opportunity'] = $task['WhatId'];\n }\n\n return $activityData;\n }\n\n /**\n * Get SF task by Outreach call id.\n */\n public function getTaskByFilter(\n string $activityFieldType,\n array $filters,\n string $operator = '=',\n array $additionalFields = []\n ): ?array {\n $data = [];\n\n try {\n // Default (base) fields.\n $fields = ['Id', 'Subject', 'Description', 'ActivityDate', 'WhoId', 'WhatId', $activityFieldType];\n\n foreach ($additionalFields as $additionalField) {\n $fields[] = $additionalField->crm_provider_id;\n }\n\n $fields = array_unique($fields);\n\n // Find task with the same Outreach id as the call id.\n $query = 'SELECT ' . implode(',', $fields) . '\n FROM Task\n WHERE IsArchived = false AND IsDeleted = false';\n\n foreach ($filters as $key => $value) {\n $key = preg_quote($key, '/');\n $key = str_replace(['\\'', '\"'], '', $key);\n // Prepare the substitution.\n $strKey = \":$key\";\n\n $query .= \" AND $key $operator $strKey\";\n }\n\n $query .= ' ORDER BY LastModifiedDate DESC LIMIT 1';\n\n $objects = $this->queryHandler->query($query, $filters);\n\n // There should be only one task related to this call if any.\n if ($objects->count() === 1) {\n $object = $objects->current();\n\n $dueDate = $object['ActivityDate'] ? Carbon::parse($object['ActivityDate'])->toIso8601String() : null;\n\n $data = array_merge($object, [\n 'crmId' => $object['Id'],\n 'subject' => $object['Subject'],\n 'summary' => $object['Description'],\n 'due' => $dueDate,\n 'Type' => $object[$activityFieldType],\n ]);\n }\n } catch (NoResultsException $e) {\n // Filters don't match any records.\n } catch (ServiceUnavailableException $serviceUnavailableException) {\n // Service cannot be queried. We should probably log this.\n }\n\n return $data;\n }\n\n /**\n * Get Salesforce fields including datetime fields\n *\n * @param $objectType\n */\n private function getAllFieldsAsArray($objectType): array\n {\n $basicFields = [];\n // Not all users have access to all object fields.\n if ($this->profile->{$objectType . '_fields'}) {\n $basicFields = explode(',', $this->profile->{$objectType . '_fields'});\n }\n\n $extraFields = [\n 'CreatedDate',\n 'LastModifiedDate',\n 'IsDeleted',\n ];\n\n if ($objectType === self::OBJECT_OPPORTUNITY\n && $this->config->opportunity_value_field_id\n && ! in_array($this->config->opportunityValueField->crm_provider_id, $basicFields)\n ) {\n $extraFields[] = $this->config->opportunityValueField->crm_provider_id;\n }\n\n return array_unique(array_merge($basicFields, $extraFields));\n }\n\n /**\n * Generate transcription for activity description.\n */\n private function generateTranscription(Activity $activity): string\n {\n if (! ($this->config->store_transcript)) {\n // If sending transcription to activity toggle is disabled\n return '';\n }\n\n return $this->transcriptionService\n ->findTranscriptionByActivity($activity)\n ->map(static function (array $transcriptionSegment): string {\n return $transcriptionSegment['formattedStartsAt'] . ' | ' . $transcriptionSegment['transcript'];\n })\n ->implode(PHP_EOL);\n }\n\n /**\n * Find related Salesforce event based on activity data\n *\n * @return array<string>\n */\n public function fetchRelatedActivity(Activity $activity): array\n {\n $this->logger->info('[Salesforce] Searching for related activity', [\n 'activityId' => $activity->getUuid(),\n 'ownerId' => $this->profile?->crm_provider_id,\n ]);\n\n $sfEvent = $this->fetchRelatedEvent($activity);\n if (empty($sfEvent)) {\n $this->logger->info('[Salesforce] No related activity found', [\n 'activityId' => $activity->getUuid(),\n 'ownerId' => $this->profile?->crm_provider_id,\n 'account' => $activity->hasAccount()\n ? $activity->getAccount()->getCrmProviderId()\n : null,\n ]);\n\n return [];\n }\n\n return $sfEvent;\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n if ($activity->isTypeConference() === false) {\n return null;\n }\n\n if ($activity->hasActualStartTime() === false && $activity->hasScheduledStartTime() === false) {\n return null;\n }\n\n if (! $activity->hasProspect()) {\n $this->logger->info('[Salesforce] Skip look up, Activity not linked to Lead, Contact or Account', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n $playbook = $this->getPlaybook($activity->getUser());\n if ($playbook !== null && $playbook->getActivityType() === Playbook::ACTIVITY_TYPE_TASK) {\n $this->logger->info('[Salesforce] Skip auto-sync for task-based playbook', [\n 'activityUuid' => $activity->getUuid(),\n 'playbookId' => $playbook->getId(),\n 'playbookType' => $playbook->getActivityType(),\n ]);\n\n return null;\n }\n\n try {\n $sfEvent = $this->fetchRelatedActivity($activity);\n if (empty($sfEvent)) {\n return null;\n }\n\n [$activityField, $activityType] = $this->resolveActivityTypeFromEvent($activity, $sfEvent);\n\n $this->logger->info('[Salesforce] Found related activity', [\n 'activityId' => $activity->getUuid(),\n 'sfEvent' => $sfEvent['Id'],\n 'activityFieldName' => $activityField,\n 'crmActivityType' => ($activityField !== null && isset($sfEvent[$activityField]))\n ? $sfEvent[$activityField]\n : null,\n 'activityType' => $activityType,\n ]);\n\n $userId = $this->findRelatedActivityUserId($activity, $sfEvent);\n\n if ($activity->getUserId() !== $userId) {\n $this->logger->info('[Salesforce] Updating meeting owner', [\n 'activityId' => $activity->getUuid(),\n 'oldUserId' => $activity->getUserId(),\n 'newUserId' => $userId,\n ]);\n }\n\n $this->updateSfEventDescription($activity, $sfEvent);\n\n $activity->update([\n 'user_id' => $userId,\n 'crm_provider_id' => $sfEvent['Id'],\n 'playbook_category_id' => $activityType->id ?? $activity->getCategory()?->getId(),\n ]);\n\n $this->logger->info('[Salesforce] Activity updated', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return $activity;\n } catch (\\Exception $exception) {\n \\Sentry::captureException($exception);\n\n throw $exception;\n }\n }\n\n /**\n * @param array<string, mixed> $sfEvent\n *\n * @return array{0: string|null, 1: mixed}\n */\n private function resolveActivityTypeFromEvent(Activity $activity, array $sfEvent): array\n {\n $activityField = $this->getActivityFieldName($activity);\n $activityType = null;\n\n if ($activityField !== null && ! empty($sfEvent[$activityField])) {\n $playbook = $this->getPlaybook($activity->getUser());\n $activityType = $this->getPlaybookCategory($playbook, strval($sfEvent[$activityField]));\n }\n\n return [$activityField, $activityType];\n }\n\n /**\n * @param array<string> $sfEvent\n */\n private function findRelatedActivityUserId(Activity $activity, array $sfEvent): int\n {\n $userId = $activity->getUserId();\n\n if (empty($sfEvent['OwnerId']) === false) {\n $profile = $this\n ->config\n ->profiles()\n ->where('crm_provider_id', $sfEvent['OwnerId'])\n ->get()\n ->filter(static function (Profile $profile) use ($activity): bool {\n if (! $activity->isTypeConference()) {\n return ! empty($profile->user) ? $profile->user->isStatusActive() : false;\n }\n\n $participants = $activity->getParticipants();\n\n return ! empty($profile->user)\n ? $profile->user->isStatusActive()\n && $profile->user->hasPermission(PermissionEnum::RECORD_MEETING)\n && $participants->contains('user_id', $profile->user_id)\n : false;\n })\n ->first();\n\n if ($profile) {\n $userId = $profile->user_id;\n }\n }\n\n return $userId;\n }\n\n /**\n * @param array<string, mixed> $sfEvent\n */\n private function updateSfEventDescription(Activity $activity, array $sfEvent): void\n {\n try {\n if (str_contains($sfEvent['Description'], $activity->id_string)) {\n return;\n }\n\n $payload = [\n 'Description' => $sfEvent['Description']\n . PHP_EOL\n . PHP_EOL\n . (new DecorateActivity())->generateDescription($activity),\n ];\n\n $this->logger->info('[Salesforce] Update record', [\n 'activityId' => $activity->getUuid(),\n 'sfEvent' => $sfEvent['Id'],\n 'payload' => $payload,\n ]);\n\n $payload = array_merge(\n $payload,\n $this->payloadBuilder->fetchCustomFieldData($activity, Field::OBJECT_EVENT)\n );\n\n $this->updateRecord('Event', $sfEvent['Id'], $payload);\n } catch (\\Exception) {\n $this->logger->error('[Salesforce] Failed to update record', [\n 'activityUuid' => $activity->getUuid(),\n 'sfEvent' => $sfEvent['Id'],\n ]);\n }\n }\n\n /**\n * Returns the most recently modified Event within time range (if any).\n *\n * @return array|null An Event record from Salesforce.\n */\n private function fetchRelatedEvent(Activity $activity): ?array\n {\n $ownerId = $this->profile?->crm_provider_id;\n if ($ownerId === null) {\n return [];\n }\n\n /** @var ?Carbon $from */\n /** @var ?Carbon $to */\n [$from, $to] = $this->getFromToDates($activity);\n\n try {\n $whoId = null;\n $hasWho = $activity->lead_id || $activity->contact_id;\n if ($hasWho) {\n $whoId = $activity->hasLead()\n ? $activity->getLead()->crm_provider_id\n : $activity->getContact()->crm_provider_id;\n }\n\n if ($hasWho === false && $activity->account_id === null) {\n return null;\n }\n\n $query = $this->buildFetchRelatedEventQuery($activity);\n\n $objects = $this->queryHandler->query($query, [\n 'ownerId' => $ownerId,\n 'whoId' => $whoId,\n 'whatId' => $activity->hasOpportunity() ? $activity->getOpportunity()->crm_provider_id : null,\n 'accountId' => $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null,\n 'from' => $from?->format('Y-m-d\\TH:i:s\\Z'),\n 'to' => $to?->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($objects as $object) {\n return $object;\n }\n } catch (NoResultsException $e) {\n return [];\n }\n\n return [];\n }\n\n private function getFromToDates(Activity $activity): array\n {\n $from = null;\n $to = null;\n\n /** @var ?CalendarEvent $calendarEvent */\n $calendarEvent = $activity->calendarEvent()->first();\n if ($calendarEvent !== null) {\n $from = $calendarEvent->getStartTime();\n $to = $calendarEvent->getEndTime();\n }\n\n // For non-calendar imported activities\n // Also double check if calendar event dates could be null?\n // If null use what we've got so far\n if ($from === null || $to === null) {\n $from = $activity->hasScheduledStartTime()\n ? $activity->getScheduledStartTime()\n : $activity->getActualStartTime();\n $to = $activity->hasScheduledEndTime()\n ? $activity->getScheduledEndTime()->addMinutes(15)\n : $activity->getActualEndTime();\n }\n\n return [$from, $to];\n }\n\n /**\n * Determines the appropriate activity field name for querying Salesforce events.\n *\n * This method follows a hierarchy to determine the field name:\n * 1. Uses the playbook's activity field if it exists and is in the profile's accessible fields\n * 2. Falls back to the default activity field if the profile has no event fields configured\n * 3. Returns null if no suitable field is found\n *\n * @param Activity $activity The activity to determine the field for\n *\n * @return string|null The field name to use in queries, or null if none is available\n */\n private function getActivityFieldName(Activity $activity): ?string\n {\n if ($this->profile === null) {\n $this->logger->warning('[Salesforce] Cannot determine activity field - profile not found', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n $profileEventFields = $this->profile->getFieldsAsArray('event');\n\n if (empty($profileEventFields)) {\n $defaultActivityField = $this->getDefaultActivityField(Field::OBJECT_EVENT);\n $defaultFieldName = $defaultActivityField?->getAttribute('crm_provider_id');\n // Profile not yet synced — fall back to the default activity field.\n // There is a small chance that the profile won't have Default Activity Type field access\n // in which case the query will fail.\n // This is however an edge case and should be reviewed for profile sync issues.\n Sentry::withScope(function (\\Sentry\\State\\Scope $scope) use ($defaultFieldName): void {\n $scope->setContext('details', [\n 'profileId' => $this->profile->id,\n 'defaultField' => $defaultFieldName,\n ]);\n Sentry::captureMessage(\n '[Salesforce] Profile event fields empty, falling back to default activity field.',\n \\Sentry\\Severity::warning()\n );\n });\n\n return $defaultFieldName;\n }\n\n $playbook = $this->getPlaybook($activity->getUser());\n\n if (! is_null($playbook) && ! is_null($playbook->getActivityField())) {\n $playbookFieldName = $playbook->getActivityField()->getAttribute('crm_provider_id');\n\n if (in_array($playbookFieldName, $profileEventFields, true)) {\n return $playbookFieldName;\n }\n\n $this->logger->warning('[Salesforce] Playbook activity field not found in profile fields', [\n 'activityId' => $activity->getUuid(),\n 'playbookField' => $playbookFieldName,\n 'profileId' => $this->profile->id,\n ]);\n }\n\n return null;\n }\n\n private function buildFetchRelatedEventQuery(Activity $activity): string\n {\n $hasWho = $activity->lead_id || $activity->contact_id;\n\n $activityFieldName = $this->getActivityFieldName($activity);\n $fields = array_filter(['Id', 'Description', 'OwnerId', $activityFieldName]);\n\n $ownerCondition = '(OwnerId = :ownerId OR CreatedById = :ownerId)';\n\n $query = '\n SELECT ' . implode(',', $fields) . '\n FROM Event\n WHERE ' . $ownerCondition . '\n AND IsArchived = false\n AND IsAllDayEvent = false\n AND StartDateTime >= :from\n AND EndDateTime <= :to\n AND (';\n\n $operator = '';\n if ($activity->account_id) {\n // This covers events tied to a related contact or opportunity too.\n $query .= 'AccountId = :accountId';\n\n $operator = ' OR ';\n }\n\n if ($hasWho) {\n $query .= $operator . 'WhoId = :whoId';\n\n // If we are also going to check on a specific opportunity, set that up.\n if ($activity->opportunity_id) {\n $query .= ' OR WhatId = :whatId';\n }\n }\n\n $query .= ') ORDER BY LastModifiedDate DESC';\n\n return $query;\n }\n\n public function fetchProspect(array $task): array\n {\n $lead = $account = $opportunity = $contact = $stage = $countryCode = null;\n $externalId = $task['WhoId'] ?? null;\n\n // Lead or Contact\n if ($externalId) {\n try {\n [$lead, $account, $opportunity, $contact, $stage, $countryCode] = $this->parseRecords($externalId);\n } catch (\\InvalidArgumentException $exception) {\n // Invalid object type.\n }\n }\n\n // If we happen to know the opportunity or account from the Task, figure that out.\n if (empty($task['WhatId']) === false) {\n // WhatId could be either Account ID or Opportunity ID.\n // If WhatId is Opportunity ID, get the opportunity and stage from the CRM.\n try {\n [, $account, $opportunity, , $stage, ] = $this->parseRecords($task['WhatId']);\n } catch (\\InvalidArgumentException $exception) {\n // Invalid object type.\n }\n }\n\n return [$lead, $account, $opportunity, $contact, $stage, $countryCode];\n }\n\n /**\n * Save activity transcription summary as note\n */\n public function saveTranscriptionSummaryAsNote(\n ActivityContract $activity,\n string $title,\n string $body,\n ?string $objectId,\n ?NoteObject $noteObject = null,\n ): ?string {\n return $this->saveNote($title, $body, (string) $objectId);\n }\n\n public function getObjectByFilterConditions(string $objectType, array $fields, array $filters): ?array\n {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $query = $queryBuilder->buildObjectSearchQuery($objectType, $fields, $filters);\n\n try {\n $objects = $this->queryHandler->query($query, $filters);\n if ($objects->count() === 1) {\n return $objects->current();\n }\n } catch (\\Exception $e) {\n $this->logger->info('[Salesforce] Failed to execute query', [\n 'query' => $query,\n 'error' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n private function getCustomProfileRules(TeamRepository $teamRepository): array\n {\n $teamSettings = $teamRepository->getTeamSetting($this->team, 'custom_profile_validation');\n\n if ($teamSettings instanceof TeamSettings && $teamSettings->getValueType() === 'array') {\n $customRules = json_decode($teamSettings->getValue(), true);\n if (is_array($customRules)) {\n return $customRules;\n }\n }\n\n return [];\n }\n\n private function customProfileValidation(array $crmUser, array $customRules): bool\n {\n foreach ($customRules as $customRule) {\n if ($crmUser[$customRule['field']] !== $customRule['value']) {\n return false;\n }\n }\n\n return true;\n }\n\n /**\n * When syncing Contact / Lead / Account / Opportunity / Stage crm entities,\n * validate and restore locally trashed objects,\n * before updating them. Objects are identified by CrmProviderId\n */\n private function restoreAnyTrashedEntity(HasMany $targetEntity, string $crmProviderId): void\n {\n $recordExists = $targetEntity->withTrashed()->where(['crm_provider_id' => $crmProviderId])->first();\n if ($recordExists && $recordExists->trashed()) {\n $recordExists->restore();\n }\n }\n\n #[\\Override] public function supportsNotes(): bool\n {\n return true;\n }\n\n private function getOwnerProfile(?string $ownerId): ?Profile\n {\n if ($ownerId === null) {\n return null;\n }\n\n return $this->config->profiles()\n ->where('crm_provider_id', $ownerId)\n ->first();\n }\n}","depth":2,"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Salesforce;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse Illuminate\\Database\\Eloquent\\Relations\\HasMany;\nuse Illuminate\\Events\\Dispatcher;\nuse Illuminate\\Support\\Facades\\Cache;\nuse Illuminate\\Support\\Facades\\Log;\nuse Illuminate\\Support\\Str;\nuse Jiminny\\Component\\Country\\CountriesMap;\nuse Jiminny\\Contracts\\Acl\\PermissionEnum;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\FetchRelatedActivityInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\ImportsBusinessProcessesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\SalesforceBatchSyncInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\SalesforceInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteNoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SearchTaskInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SendSummaryToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SupportsObjectTypeParseInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmProfileRecordTypesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Enums\\CrmObject;\nuse Jiminny\\Events\\Activities\\Crm\\LeadConverted;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpBadRequestException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Exceptions\\NoResultsException;\nuse Jiminny\\Exceptions\\ServiceUnavailableException;\nuse Jiminny\\Jobs\\Crm\\NoteObject;\nuse Jiminny\\Jobs\\Crm\\MatchActivitiesToNewOpportunity;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Calendar\\CalendarEvent;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Contracts\\ActivityContract;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Configuration;\nuse Jiminny\\Models\\Crm\\ContactRole;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Crm\\RecordType;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\TeamSettings;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\ContactRoleRepository;\nuse Jiminny\\Repositories\\Crm\\FieldRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Repositories\\Crm\\RecordTypeFieldValuesRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\Helpers\\ArrayIterator;\nuse Jiminny\\Services\\Crm\\MatchDomainByEmailInterface;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Services\\Crm\\ResolveCompanyNameByEmailTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\Fields\\FieldHelper;\nuse Jiminny\\Services\\Crm\\Salesforce\\Fields\\FieldTypeConverter;\nuse Jiminny\\Services\\Crm\\Salesforce\\Fields\\ValueNormalizer;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\FollowupActivityTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\LogActivityTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\RecordManipulationsTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\SyncFieldsTrait;\nuse Jiminny\\Utils\\CurrencyFormatter;\nuse Jiminny\\Utils\\StringUtil;\nuse Ramsey\\Uuid\\Uuid;\nuse Sentry\\Laravel\\Facade as Sentry;\n\nclass Service extends BaseService implements\n SalesforceInterface,\n SalesforceBatchSyncInterface,\n SyncCrmEntitiesInterface,\n SyncCrmProfileRecordTypesInterface,\n ImportsBusinessProcessesInterface,\n RemoteEntityManipulationInterface,\n FetchRelatedActivityInterface,\n SendSummaryToCrmInterface,\n MatchDomainByEmailInterface,\n SearchTaskInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n SupportsObjectTypeParseInterface,\n RemoteNoteEntityManipulationInterface,\n VerifyTaskExistsInterface\n{\n use ResolveCompanyNameByEmailTrait;\n use SyncFieldsTrait;\n use DeleteObjectsTrait;\n use RecordManipulationsTrait;\n use ServiceTraits\\BatchSyncTrait;\n use FollowupActivityTrait;\n use LogActivityTrait;\n\n /**\n * Note Body Limit for the Old Note-Taking Tool\n *\n * @var int\n */\n private const int CLASSIC_NOTE_MAX_LENGTH = 32000;\n\n /**\n * Note Content Limit for the New Notes\n *\n * @var int\n */\n private const int ENHANCED_NOTE_MAX_LENGTH = 50000000;\n\n private const string INSTALLED_PACKAGE_ID = '033Tw0000007bKbIAI';\n\n private const int CACHE_TTL = 600;\n\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day - 86400\n\n /**\n * @var Client\n */\n protected $client;\n\n protected PayloadBuilder $payloadBuilder;\n protected QueryHandler $queryHandler;\n\n private OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n\n public function __construct(\n Client $client,\n PayloadBuilder $payloadBuilder,\n protected Dispatcher $eventDispatcher,\n private readonly CountriesMap $countriesMap,\n private readonly ProspectPhotoPathService $prospectPhotoPathService,\n ) {\n parent::__construct();\n\n $this->client = $client;\n $this->payloadBuilder = $payloadBuilder;\n $this->queryHandler = app(QueryHandler::class, [\n 'client' => $this->client,\n 'logger' => $this->logger,\n ]);\n $this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n }\n\n public function getDisplayName(): string\n {\n return 'Salesforce';\n }\n\n public function getJobDelay(): int\n {\n return 1;\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n return $user->getSocialAccount(SocialAccount::PROVIDER_SALESFORCE);\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($activity, $crmProviderId) {\n $playbook = $this->getPlaybookFromActivity($activity);\n\n if ($playbook === null) {\n $this->logger->warning('[Salesforce] Cannot verify task - no playbook found', [\n 'activity' => $activity->getId(),\n 'crm_provider_id' => $crmProviderId,\n ]);\n\n return false;\n }\n\n $objectType = $playbook->getActivityType() === Playbook::ACTIVITY_TYPE_EVENT ? 'Event' : 'Task';\n\n try {\n $record = $this->getRecord($objectType, $crmProviderId, ['Id', 'IsDeleted']);\n\n return ! empty($record) && ($record['IsDeleted'] ?? false) === false;\n } catch (HttpNotFoundException|HttpBadRequestException) {\n $this->logger->info('[Salesforce] Activity record not found during verification', [\n 'activity' => $activity->getId(),\n 'object_type' => $objectType,\n 'crm_provider_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n });\n }\n\n public function query(string $queryToRun, array $parameters = []): QueryIterator\n {\n // Due to poorly designed external calls, this method cannot be entirely removed\n return $this->queryHandler->query($queryToRun, $parameters);\n }\n\n /*=========== Organization Information ===============*/\n\n /**\n * Get a list of all the API Versions for the instance.\n *\n * @throws CrmException\n *\n * @return mixed\n *\n */\n public function getApiVersions()\n {\n $url = $this->config->crm_base_url . '/services/data';\n\n $response = $this->client->get($url);\n\n return json_decode($response->getBody(), true);\n }\n\n /**\n * Gets the valid recordTypes for a given Salesforce Object via the describe API.\n */\n private function getRecordTypes(string $crmObject): array\n {\n $url = $this->client->getObjectsUrl() . $crmObject . '/describe';\n\n $response = $this->client->get($url);\n $jsonResponse = json_decode($response->getBody(), true);\n\n $fields = [];\n foreach ($jsonResponse['recordTypeInfos'] as $row) {\n $fields[] = ['recordTypeId' => $row['recordTypeId'], 'default' => $row['defaultRecordTypeMapping']];\n }\n\n return $fields;\n }\n\n /**\n * Convert raw field data into a format compatible with CRM APIs.\n */\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return ValueNormalizer::normalize($fieldType, $fieldValue, $internal);\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n $defaultFields = ($activityType === Playbook::ACTIVITY_TYPE_TASK)\n ? FieldDefinitions::defaultTaskFields()\n : FieldDefinitions::defaultEventFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n // Setup the activity field as the default Type.\n /** @var Field $activityField */\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'Type',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK, Playbook::ACTIVITY_TYPE_EVENT];\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n\n $fieldFilter = ($activityType === Playbook::ACTIVITY_TYPE_TASK)\n ? FieldDefinitions::taskFollowupFieldsFilter()\n : FieldDefinitions::eventFollowupFieldsFilter();\n\n foreach ($fieldFilter as $eachFilter) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $eachFilter);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n /**\n * This one is now called only when ImportActivityTypes is triggered or SyncFieldMetadata executed manually\n * Regular sync now uses SharedSyncFieldsTrait -> syncSingleObjectType\n * Needs to be replaced later on\n */\n public function syncField(Field $field): void\n {\n try {\n if (FieldHelper::isCustomField($field)) {\n $query = '\n SELECT\n Id, Metadata, TableEnumOrId\n FROM\n CustomField\n WHERE\n DeveloperName = :fieldName\n AND\n TableEnumOrId = :fieldType\n AND\n NamespacePrefix = :namespacePrefix';\n\n // We need to constrain the field lookup to the object, in case it's used in multiple places.\n $objectType = \\in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true)\n ? 'activity'\n : $field->object_type;\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'fieldName' => substr($field->crm_provider_id, 0, -\\strlen('__c')),\n 'fieldType' => ucfirst($objectType),\n\n // This is used to ensure we only consider the field within the org, not installed packages.\n 'namespacePrefix' => 'null',\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n // Sync field metadata.\n $metadata = $sfField['Metadata'];\n\n $field->description = mb_strimwidth($metadata['description'] ?? '', 0, 191);\n $field->label = mb_strimwidth($metadata['label'] ?? '', 0, Field::LABEL_MAX_LENGTH);\n $field->type = $this->convertFieldType($metadata['type'], $field->getEntityName());\n $field->is_mandatory = ($metadata['required'] === true);\n $field->length = $metadata['length'];\n $field->default_value = mb_strimwidth(trim($metadata['defaultValue'] ?? '', '\"'), 0, 191);\n $field->save();\n } else {\n $query = '\n SELECT\n Id, DataType, DeveloperName, Label, Length, Description\n FROM\n FieldDefinition\n WHERE\n DurableId = :entityName';\n\n $entityName = $field->getEntityName();\n $sfFields = $this->queryHandler->metadata($query, [\n 'entityName' => $entityName,\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n $convertedType = $this->convertFieldType($sfField['DataType'], $entityName);\n $label = mb_strimwidth($sfField['Label'], 0, Field::LABEL_MAX_LENGTH);\n\n if ($field->isBusinessType()) {\n $label = 'Opportunity Type';\n }\n\n $field->description = mb_strimwidth($sfField['Description'], 0, Field::DESCRIPTION_MAX_LENGTH);\n $field->label = $label;\n $field->type = $convertedType;\n $field->length = $sfField['Length'];\n $field->save();\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n private function convertFieldType(string $from, ?string $entityName = null): string\n {\n $converter = new FieldTypeConverter();\n\n return $converter->convert($from, $entityName);\n }\n\n /**\n * @inheritdoc\n */\n public function importPicklistValues(Field $field): array\n {\n $values = [];\n $fieldValues = [];\n\n try {\n if (FieldHelper::isCustomField($field)) {\n $query = '\n SELECT\n Id, Metadata, TableEnumOrId\n FROM\n CustomField\n WHERE\n DeveloperName = :fieldName\n AND\n TableEnumOrId = :fieldType\n AND\n NamespacePrefix = :namespacePrefix';\n\n // We need to constrain the field lookup to the object, in case it's used in multiple places.\n $objectType = \\in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true) ?\n 'activity' : $field->object_type;\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'fieldName' => substr($field->crm_provider_id, 0, -\\strlen('__c')),\n 'fieldType' => ucfirst($objectType),\n // This is used to ensure we only consider the field within the org, not installed packages.\n 'namespacePrefix' => 'null',\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n $valueSet = $sfField['Metadata']['valueSet'];\n\n if ($valueSet['valueSetName'] === null) {\n // Local picklist values can be obtained easily.\n $picklistValues = $valueSet['valueSetDefinition']['value'];\n } else {\n // But for some fields, we just get the Global Value Picklist pointer so need to do more work.\n $picklistValues = $this->importGlobalValuePicklistValues($valueSet['valueSetName']);\n }\n\n // Import all active values.\n foreach ($picklistValues as $i => $sfFieldValue) {\n // Setup default value.\n if ($sfFieldValue['default']) {\n $field->update(['default_value' => $sfFieldValue['valueName']]);\n }\n\n // This comes through as null if active (lol).\n if ($sfFieldValue['isActive'] !== false) {\n $values[] = [\n 'value' => $sfFieldValue['valueName'],\n 'label' => $sfFieldValue['valueName'],\n 'sequence' => $i,\n 'is_default' => $sfFieldValue['default'],\n ];\n }\n }\n } else {\n $objectFields = $this->getObjectFields($field->object_type);\n $fieldId = $field->crm_provider_id;\n\n // Only work with our field of interest.\n $objectField = array_filter($objectFields, function ($item) use ($fieldId) {\n return $item['name'] === $fieldId;\n });\n\n $objectField = array_shift($objectField);\n if (empty($objectField['picklistValues']) === false) {\n foreach ($objectField['picklistValues'] as $i => $sfFieldValue) {\n // Skip inactive values.\n if ($sfFieldValue['active'] === false) {\n continue;\n }\n\n // Setup default value.\n if ($sfFieldValue['defaultValue']) {\n $field->update(['default_value' => $sfFieldValue['value']]);\n }\n\n $values[] = [\n 'value' => $sfFieldValue['value'],\n 'label' => $sfFieldValue['label'],\n 'sequence' => $i,\n 'is_default' => $sfFieldValue['defaultValue'],\n ];\n }\n }\n }\n\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n $value['value'] = substr($value['value'] ?? '', 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n if (($key = array_search($value['value'], $fieldsToPurge, true)) !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n // Get IDs of the values to be deleted\n $valuesToDelete = $field->values()->whereIn('value', $fieldsToPurge);\n $valuesToDeleteIds = $valuesToDelete->pluck('id');\n if (! $valuesToDeleteIds->isEmpty()) {\n $recordTypeFieldValuesRepository = app(RecordTypeFieldValuesRepository::class);\n $recordTypeFieldValuesRepository->deleteForCrmFieldValueIds($valuesToDeleteIds->toArray());\n\n // Now safely delete from crm_field_values\n $valuesToDelete->delete();\n }\n\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n return $fieldValues;\n }\n\n /**\n * Gets values from Global Value Picklists.\n */\n private function importGlobalValuePicklistValues(string $picklistName): array\n {\n $query = '\n SELECT\n Metadata\n FROM\n GlobalValueSet\n WHERE\n DeveloperName = :picklistName\n LIMIT 1';\n\n try {\n $sfValues = $this->queryHandler->metadata($query, [\n 'picklistName' => $picklistName,\n ]);\n\n // There is always 1 result at this point.\n $sfValue = $sfValues->current();\n\n return $sfValue['Metadata']['customValue'];\n } catch (NoResultsException $noResultsException) {\n // Nothing returned.\n\n return [];\n }\n }\n\n /**\n * @inheritdoc\n */\n public function syncProfileRecordTypes(): void\n {\n $objectTypes = [\n 'lead',\n 'account',\n 'contact',\n 'opportunity',\n 'task',\n 'event',\n ];\n\n foreach ($objectTypes as $objectType) {\n try {\n $crmRecordTypes = $this->getRecordTypes(ucfirst($objectType));\n\n foreach ($crmRecordTypes as $crmRecordType) {\n // If the record type is default and not the Master type, set this.\n if ($crmRecordType['default'] && $crmRecordType['recordTypeId'] !== '012000000000000AAA') {\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $crmRecordType['recordTypeId'])\n ->first();\n\n if ($recordType) {\n $this->profile->{$objectType . '_record_type_id'} = $recordType->id;\n }\n }\n }\n } catch (HttpNotFoundException $exception) {\n Log::error('No access to ' . $objectType . ' object, skipping...');\n\n // XXX: should we log this fact somewhere?\n continue;\n }\n }\n\n if ($this->profile->isDirty()) {\n $this->profile->save();\n }\n }\n\n /**\n * Gets business processes.\n */\n public function importBusinessProcesses(): void\n {\n $query = '\n SELECT\n Id, IsActive, Name, TableEnumOrId\n FROM\n BusinessProcess\n WHERE\n TableEnumOrId IN (\\'Lead\\',\\'Opportunity\\')';\n\n try {\n $sfProcesses = $this->queryHandler->query($query);\n\n // Upsert all processes for the team.\n foreach ($sfProcesses as $sfProcess) {\n /** @var BusinessProcess $businessProcess */\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $sfProcess['Id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => $sfProcess['Name'],\n 'type' => $sfProcess['TableEnumOrId'] === 'Lead' ? 'lead' : 'opportunity',\n 'is_selectable' => $sfProcess['IsActive'],\n ]);\n\n $this->importBusinessProcessStages($businessProcess);\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n /**\n * Gets business process stages.\n */\n private function importBusinessProcessStages(BusinessProcess $businessProcess): void\n {\n $query = '\n SELECT\n Metadata\n FROM\n BusinessProcess\n WHERE\n Id = :processId';\n\n try {\n $stages = [];\n $sfProcessStages = $this->queryHandler->metadata($query, [\n 'processId' => $businessProcess->crm_provider_id,\n ]);\n\n // There is always 1 result at this point.\n $sfProcessStage = $sfProcessStages->current();\n\n // Upsert all processes for the team.\n foreach ($sfProcessStage['Metadata']['values'] as $sfProcessStage) {\n $sanitizedName = urldecode($sfProcessStage['valueName']); // Must decode: \"%2C\" becomes \",\" etc.\n\n $stage = $businessProcess->crm->stages()\n // This MUST match on label because this API doesn't use API Name.\n ->where('label', $sanitizedName)\n ->where('type', $businessProcess->type)\n ->where('is_selectable', 1)\n ->first();\n\n if ($stage) {\n $stages[] = $stage->id;\n }\n }\n\n $businessProcess->stages()->sync($stages);\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n /**\n * Gets record types.\n */\n public function importRecordTypes(): void\n {\n $query = '\n SELECT\n Id, IsActive, Name, BusinessProcessId, SobjectType\n FROM\n RecordType';\n\n try {\n $sfRecordTypes = $this->queryHandler->query($query);\n\n // Upsert all record types for the process.\n foreach ($sfRecordTypes as $sfRecordType) {\n $businessProcess = null;\n if ($sfRecordType['BusinessProcessId']) {\n $businessProcess = $this->config->businessProcesses()\n ->where('crm_provider_id', $sfRecordType['BusinessProcessId'])\n ->first();\n }\n\n /** @var RecordType $recordType */\n $recordType = $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $sfRecordType['Id'],\n ], [\n 'team_id' => $this->team->id,\n 'type' => mb_strtolower($sfRecordType['SobjectType']),\n 'name' => $sfRecordType['Name'],\n 'is_selectable' => $sfRecordType['IsActive'],\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n $this->importRecordTypeFieldValues($recordType);\n }\n } catch (NoResultsException $noResultsException) {\n // Do nothing.\n }\n }\n\n /**\n * Import record type - field value mappings. This only works for standard fields.\n */\n private function importRecordTypeFieldValues(RecordType $recordType): void\n {\n try {\n $query = '\n SELECT\n Metadata\n FROM\n RecordType\n WHERE\n Id = :recordTypeId';\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'recordTypeId' => $recordType->crm_provider_id,\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n // Sync field metadata.\n $picklists = $sfField['Metadata']['picklistValues'];\n\n foreach ($picklists as $picklist) {\n $field = $this->config->fields()->where([\n 'type' => Field::TYPE_PICKLIST,\n 'object_type' => $recordType->type,\n 'crm_provider_id' => $picklist['picklist'],\n ])->first();\n\n if ($field) {\n $fieldValues = [];\n\n foreach ($picklist['values'] as $value) {\n // Must decode: \"%2C\" becomes \",\" etc.\n $fieldValue = $field->values()\n ->where('value', urldecode($value['valueName']))\n ->first();\n\n if ($fieldValue) {\n $fieldValues[] = $fieldValue->id;\n }\n }\n\n $recordType->fieldValues()->sync($fieldValues);\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n /**\n * @inheritdoc\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $params = [];\n $missingStage = null;\n if ($types === null) {\n $types = [Stage::TYPE_LEAD, Stage::TYPE_OPPORTUNITY];\n }\n\n foreach ($types as $type) {\n if ($type === Stage::TYPE_LEAD) {\n $query = '\n SELECT\n Id, ApiName, MasterLabel, SortOrder\n FROM\n LeadStatus';\n } else {\n $query = '\n SELECT\n Id, ApiName, MasterLabel, IsActive, SortOrder, DefaultProbability\n FROM\n OpportunityStage';\n }\n\n if ($missingStageName) {\n $escapedStageName = ValueNormalizer::replaceQueryWithStringLiterals($missingStageName);\n\n $query .= ' WHERE ApiName = :stageName';\n\n $params = [\n 'stageName' => $escapedStageName,\n ];\n }\n\n try {\n $sfStages = $this->queryHandler->query($query, $params);\n } catch (NoResultsException $exception) {\n $sfStages = [];\n }\n\n $missingStage = null;\n\n // Upsert all stages for the team.\n foreach ($sfStages as $sfStage) {\n $selectable = true;\n if (array_key_exists('IsActive', $sfStage)) {\n $selectable = $sfStage['IsActive'];\n }\n\n $this->restoreAnyTrashedEntity($this->config->stages(), $sfStage['Id']);\n\n $stage = $this->config->stages()->updateOrCreate([\n 'crm_provider_id' => $sfStage['Id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($sfStage['ApiName'], 0, 50),\n 'label' => mb_strimwidth($sfStage['MasterLabel'], 0, 191),\n 'type' => $type,\n 'sequence' => $sfStage['SortOrder'] ?? 0,\n 'is_selectable' => $selectable,\n 'probability' => $sfStage['DefaultProbability'] ?? null,\n ]);\n\n if ($missingStageName && $missingStageName === $sfStage['ApiName']) {\n $missingStage = $stage;\n }\n }\n\n if ($missingStageName && $missingStage === null) {\n // If they requested a stage that still doesn't exist, it must be inactive so lazy create it.\n $missingStage = $this->config->stages()->create([\n 'crm_provider_id' => Uuid::uuid4(),\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($missingStageName, 0, 50),\n 'label' => mb_strimwidth($missingStageName, 0, 191),\n 'type' => $type,\n 'sequence' => 0,\n 'is_selectable' => 0,\n ]);\n }\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int\n {\n $syncCount = 0;\n $fields = $this->getAllFieldsAsArray('lead');\n if (\\in_array('Id', $fields, true) === false) {\n return $syncCount;\n }\n\n $query = '\n SELECT ' . rtrim(implode(',', $fields), ',') . '\n FROM Lead\n WHERE LastModifiedDate > :since\n ORDER BY LastModifiedDate ASC';\n\n try {\n $sfLeads = $this->queryHandler->query($query, [\n 'since' => $since->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($sfLeads as $sfLead) {\n // Only sync if previously imported.\n if ($this->hasLead($sfLead['Id'])) {\n $this->importLead($sfLead);\n $syncCount++;\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::LEAD);\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLead(string $crmId): ?Lead\n {\n $fields = $this->getAllFieldsAsArray('lead');\n\n $sfLead = $this->getRecord('Lead', $crmId, $fields);\n\n return $this->importLead($sfLead);\n }\n\n private function importLead($crmData): ?Lead\n {\n /** @var ?Stage $stage */\n $stage = null;\n if (isset($crmData['Status'])) {\n // Get the current stage.\n $stage = $this->config\n ->stages()\n ->where('name', $crmData['Status'])\n ->where('type', Stage::TYPE_LEAD)\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_LEAD], $crmData['Status']);\n }\n }\n\n // If we have no way of importing this, just return null :(\n if ($stage === null) {\n return null;\n }\n\n $countryCode = $crmData['CountryCode'] ?? null;\n\n // Salesforce allows custom \"countries\" to be created. Disregard these.\n if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {\n $countryCode = null;\n }\n\n // If we have no country code, try to parse it from the country name.\n if ($countryCode === null && empty($crmData['Country']) !== false) {\n $countryCode = $this->convertCountryNameToCode($crmData['Country']);\n }\n\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($crmData['Phone'] ?? '', 0, 25);\n $parsedNumber = parsePhoneNumber($countryCode, $number);\n\n $mobilePhone = null;\n if (empty($crmData['MobilePhone']) === false) {\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($crmData['MobilePhone'], 0, 25);\n $mobilePhone = phone_e164($countryCode, $number);\n }\n\n $convertedDate = null;\n $convertedAccount = null;\n $convertedOpportunity = null;\n $convertedContact = null;\n\n if ($crmData['IsConverted'] == 'true') {\n $convertedDate = $crmData['ConvertedDate'];\n\n if (empty($crmData['ConvertedAccountId']) === false) {\n $convertedAccount = $this->config\n ->accounts()\n ->where('crm_provider_id', $crmData['ConvertedAccountId'])\n ->first();\n\n if ($convertedAccount === null) {\n try {\n $convertedAccount = $this->syncAccount($crmData['ConvertedAccountId']);\n } catch (HttpNotFoundException $exception) {\n // Probably the user has no permissions to access the converted data.\n }\n }\n }\n\n if (empty($crmData['ConvertedOpportunityId']) === false) {\n $convertedOpportunity = $this->config\n ->opportunities()\n ->where('crm_provider_id', $crmData['ConvertedOpportunityId'])\n ->first();\n\n if ($convertedOpportunity === null) {\n try {\n $convertedOpportunity = $this->syncOpportunity($crmData['ConvertedOpportunityId']);\n } catch (HttpNotFoundException $exception) {\n // Probably the user has no permissions to access the converted data.\n }\n }\n }\n\n if (empty($crmData['ConvertedContactId']) === false) {\n $convertedContact = $this->team\n ->crm\n ->contacts()\n ->where('crm_provider_id', $crmData['ConvertedContactId'])\n ->first();\n\n if ($convertedContact === null) {\n try {\n $convertedContact = $this->syncContact($crmData['ConvertedContactId']);\n } catch (HttpNotFoundException $exception) {\n // Probably the user has no permissions to access the converted data.\n }\n }\n }\n }\n\n if (empty($crmData['Company'])) {\n $company = 'Unknown';\n } else {\n $company = mb_strimwidth($crmData['Company'], 0, 191);\n }\n\n $domain = null;\n if (empty($crmData['Website']) === false) {\n $domain = mb_strimwidth($crmData['Website'], 0, 191);\n $domain = StringUtil::resolveDomain($domain);\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $data = [\n 'team_id' => $this->team->id,\n 'user_id' => $profile?->user_id,\n 'owner_id' => $crmData['OwnerId'] ?? '',\n 'company' => $company,\n 'domain' => $domain,\n 'name' => $crmData['Name'] ? mb_strimwidth($crmData['Name'], 0, 191) : '',\n 'title' => $crmData['Title'] ? mb_strimwidth($crmData['Title'], 0, 128) : null,\n 'email' => $crmData['Email'] ? mb_strimwidth($crmData['Email'], 0, 80) : null,\n 'phone' => $parsedNumber['phone'],\n 'ext' => $parsedNumber['ext'] ?? null,\n 'mobile_phone' => $mobilePhone,\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['Id'],\n modelType: Lead::class,\n fileName: $crmData['Id'],\n avatarText: $crmData['Name']\n ),\n 'stage_id' => $stage->id,\n 'record_type_id' => null,\n 'converted_at' => $convertedDate,\n 'converted_account_id' => $convertedAccount->id ?? null,\n 'converted_opportunity_id' => $convertedOpportunity->id ?? null,\n 'converted_contact_id' => $convertedContact->id ?? null,\n 'country_code' => $countryCode,\n 'remotely_created_at' => $createdDate,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->leads(), $crmData['Id']);\n\n /** @var Lead $lead */\n $lead = $this->config->leads()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n if ($lead->wasChanged('converted_at') && $lead->getConvertedAt() !== null) {\n $this->eventDispatcher->dispatch(new LeadConverted($lead));\n }\n\n $this->handleObjectDeletion($lead, $crmData);\n\n if ($lead->trashed()) {\n return null;\n }\n\n return $lead;\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccounts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n $fields = $this->getAllFieldsAsArray('account');\n\n if (\\in_array('Id', $fields, true) === false) {\n return $syncCount;\n }\n\n $query = '\n SELECT ' . rtrim(implode(',', $fields), ',') . '\n FROM Account\n WHERE LastModifiedDate > :since\n ORDER BY LastModifiedDate ASC';\n\n try {\n $sfAccounts = $this->queryHandler->query($query, [\n 'since' => $since->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($sfAccounts as $sfAccount) {\n // Only sync if previously imported.\n if ($this->hasAccount($sfAccount['Id'])) {\n $this->importAccount($sfAccount);\n $syncCount++;\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::ACCOUNT);\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccount(string $crmId): ?Account\n {\n $fields = $this->getAllFieldsAsArray('account');\n if (! in_array('Id', $fields, true)) {\n $this->logger->info('[Salesforce] Sync account cancelled. Fields are not available.', [\n 'crmId' => $crmId,\n 'userId' => $this->profile->getUserId(),\n ]);\n\n return null;\n }\n\n $sfAccount = $this->getRecord('Account', $crmId, $fields);\n\n return $this->importAccount($sfAccount);\n }\n\n private function importAccount($crmData): Account\n {\n $countryCode = $crmData['BillingCountryCode'] ?? $crmData['ShippingCountryCode'] ?? null;\n\n // Salesforce allows custom \"countries\" to be created. Disregard these.\n if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {\n $countryCode = null;\n }\n\n // If we have no country code, try to parse it from the country names.\n if ($countryCode === null && empty($crmData['BillingCountry']) === false) {\n $countryCode = $this->convertCountryNameToCode($crmData['BillingCountry']);\n }\n\n if ($countryCode === null && empty($crmData['ShippingCountry']) === false) {\n $countryCode = $this->convertCountryNameToCode($crmData['ShippingCountry']);\n }\n\n if (empty($crmData['Phone']) === false) {\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($crmData['Phone'], 0, 25);\n $parsedNumber = parsePhoneNumber($countryCode, $number);\n } else {\n $parsedNumber = [];\n }\n\n $industry = null;\n if (empty($crmData['Industry']) === false) {\n $industry = mb_strimwidth($crmData['Industry'], 0, 40);\n }\n\n $domain = null;\n if (empty($crmData['Website']) === false) {\n $domain = mb_strimwidth($crmData['Website'], 0, 191);\n $domain = StringUtil::resolveDomain($domain);\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $data = [\n 'team_id' => $this->team->id,\n 'user_id' => $profile?->user_id,\n 'owner_id' => $crmData['OwnerId'],\n 'name' => mb_strimwidth($crmData['Name'], 0, 191),\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['Id'],\n modelType: Account::class,\n fileName: $crmData['Id'],\n avatarText: $crmData['Name']\n ),\n 'industry' => $industry,\n 'domain' => $domain,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'ext' => $parsedNumber['ext'] ?? null,\n 'country_code' => $countryCode,\n 'remotely_created_at' => $createdDate,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->accounts(), $crmData['Id']);\n\n /** @var Account $account */\n $account = $this->config->accounts()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n $this->handleObjectDeletion($account, $crmData);\n\n return $account;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOpportunities(array $parameters, ?string $strategy = null): int\n {\n $strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);\n\n $syncCount = 0;\n $logParams = $parameters;\n $parameters['profile'] = $this->profile;\n $logParams['user'] = $this->profile->getUserId();\n\n if (count($strategies) > 1) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Multiple sync strategies used', [\n 'teamId' => $this->team->getUuid(),\n 'params' => $logParams,\n 'strategies_count' => count($strategies),\n ]);\n }\n\n foreach ($strategies as $syncStrategy) {\n $name = $syncStrategy->getStrategyName();\n\n try {\n $sfOpportunities = $syncStrategy->fetchOpportunities($parameters);\n $totalRecords = $sfOpportunities->count();\n\n foreach ($sfOpportunities as $sfOpportunity) {\n $this->importOpportunity($sfOpportunity);\n $syncCount++;\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n $this->logger->warning('[' . $this->getDisplayName() . '] No opportunities found', [\n 'teamId' => $this->team->getUuid(),\n 'name' => $name,\n 'params' => $logParams,\n 'reason' => $noResultsException->getMessage(),\n ]);\n } catch (CrmException $crmException) {\n // Nothing to sync.\n $this->logger->warning('[' . $this->getDisplayName() . '] Opportunity sync failed', [\n 'teamId' => $this->team->getUuid(),\n 'name' => $name,\n 'params' => $logParams,\n 'reason' => $crmException->getMessage(),\n ]);\n }\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::OPPORTUNITY, ['params' => $logParams]);\n\n // debug to see how if count of opportunities reaches 1000\n if ($syncCount >= 1000) {\n $this->logger->info(\n '[' . $this->getDisplayName() . '] Sync Opportunities - count warning',\n [\n 'team_id' => $this->team->getId(),\n 'params' => $logParams,\n 'count' => $syncCount,\n 'strategies_count' => count($strategies),\n 'total_records' => $totalRecords ?? null,\n ]\n );\n }\n\n return $syncCount;\n }\n\n\n /**\n * @inheritdoc\n */\n public function syncOpportunity(string $crmId): ?Opportunity\n {\n $strategy = $this->opportunitySyncStrategyResolver->resolve(\n $this->config,\n OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY\n );\n\n $parameters = [\n 'profile' => $this->profile,\n 'crm_id' => $crmId,\n ];\n\n try {\n $sfOpportunity = $strategy->fetchOpportunities($parameters);\n } catch (HttpNotFoundException $e) {\n $this->logger->info('[' . $this->getDisplayName() . '] Opportunity not found', [\n 'teamId' => $this->team->id_string,\n 'crmId' => $crmId,\n ]);\n\n return null;\n } catch (CrmException $crmException) {\n $this->logger->info('[' . $this->getDisplayName() . '] Opportunity sync failed', [\n 'teamId' => $this->team->id_string,\n 'crmId' => $crmId,\n 'exception' => $crmException->getMessage(),\n ]);\n\n return null;\n }\n\n if ($sfOpportunity instanceof ArrayIterator) {\n return $this->importOpportunity($sfOpportunity->getItems());\n }\n\n return $this->importOpportunity($sfOpportunity);\n }\n\n /**\n * @throws HttpNotFoundException\n */\n private function importOpportunity($crmData): ?Opportunity\n {\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $account = null;\n if (empty($crmData['AccountId']) === false) {\n /** @var ?Account $account */\n $account = $this->config->accounts()\n ->where('crm_provider_id', (string) $crmData['AccountId'])\n ->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmData['AccountId']);\n }\n }\n\n $userId = $profile?->getUserId() ?? $account?->getUserId();\n if ($userId === null) {\n $this->logger->error('[Salesforce] | Skip import, no user_id found', [\n 'id' => $crmData['Id'],\n ]);\n\n return null;\n }\n\n /** @var ?Stage $stage */\n $stage = null;\n if (isset($crmData['StageName'])) {\n $stage = $this->config\n ->stages()\n ->where('name', $crmData['StageName'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->orderBy('is_selectable', 'DESC')\n ->orderBy('id')\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $crmData['StageName']);\n }\n }\n\n $recordType = null;\n if (empty($crmData['RecordTypeId']) === false) {\n /** @var ?RecordType $recordType */\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $crmData['RecordTypeId'])\n ->first();\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $closeDate = null;\n if (empty($crmData['CloseDate']) === false) {\n $closeDate = Carbon::parse($crmData['CloseDate'])->format('Y-m-d');\n }\n\n $valueFieldName = 'Amount';\n if ($this->config->opportunity_value_field_id) {\n $valueFieldName = $this->config->opportunityValueField->crm_provider_id;\n }\n\n $data = [\n 'team_id' => $this->team->id,\n 'account_id' => $account->id ?? null,\n 'user_id' => $userId,\n 'owner_id' => $crmData['OwnerId'] ?? null,\n 'name' => mb_strimwidth($crmData['Name'] ?? '', 0, 128),\n 'value' => $crmData[$valueFieldName],\n 'currency_code' => CurrencyFormatter::formatCode($crmData['CurrencyIsoCode'] ?? null),\n 'close_date' => $closeDate,\n 'is_closed' => $crmData['IsClosed'],\n 'is_won' => $crmData['IsWon'],\n 'stage_id' => $stage?->id ?? null,\n 'record_type_id' => $recordType->id ?? null,\n 'remotely_created_at' => $createdDate,\n 'probability' => $crmData['Probability'] ?? null,\n 'forecast_category' => $crmData['ForecastCategoryName'] ?? null,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->opportunities(), $crmData['Id']);\n\n // Do not allow locked DB tables & other errors\n // to interrupt the process of reverting the trashed opportunities\n try {\n /** @var Opportunity $opportunity */\n $opportunity = $this->config->opportunities()\n ->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n // import external fields into crm_field_data if present\n $crmFields = $this->getOpportunitySyncableFields();\n\n $this->importOpportunityCrmFieldData($crmData, $crmFields, $opportunity->id);\n\n $this->handleObjectDeletion($opportunity, $crmData);\n\n if ($opportunity->trashed()) {\n return null;\n }\n\n if ($opportunity->wasRecentlyCreated) {\n MatchActivitiesToNewOpportunity::dispatch($opportunity->getId());\n }\n\n return $opportunity;\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n $this->logger->error('[Salesforce] importOpportunity failure.', [\n 'crm_provider_id' => $crmData['Id'],\n 'team_id' => $this->team->id,\n 'exception' => $exception->getMessage(),\n ]);\n\n $this->handleEntityDeletionByProviderId($this->config->opportunities(), $crmData);\n }\n\n return null;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContacts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n $fields = $this->getAllFieldsAsArray('contact');\n if (\\in_array('Id', $fields, true) === false) {\n return $syncCount;\n }\n\n $query = '\n SELECT ' . rtrim(implode(',', $fields), ',') . '\n FROM Contact\n WHERE LastModifiedDate > :since\n ORDER BY LastModifiedDate ASC';\n\n try {\n $sfContacts = $this->queryHandler->query($query, [\n 'since' => $since->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($sfContacts as $sfContact) {\n // Only sync if previously imported.\n if ($this->hasContact($sfContact['Id'])) {\n $this->importContact($sfContact);\n $syncCount++;\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::CONTACT);\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContact(string $crmId): ?Contact\n {\n $fields = $this->getAllFieldsAsArray('contact');\n if (! in_array('Id', $fields, true)) {\n $this->logger->info('[Salesforce] Sync contact cancelled. Fields are not available.', [\n 'crmId' => $crmId,\n 'userId' => $this->profile->getUserId(),\n ]);\n\n return null;\n }\n\n $sfContact = $this->getRecord('Contact', $crmId, $fields);\n\n return $this->importContact($sfContact);\n }\n\n private function importContact($crmData): ?Contact\n {\n $account = null;\n // Contacts may not have accounts...\n if (isset($crmData['AccountId'])) {\n $account = $this->config->accounts()\n ->where('crm_provider_id', (string) $crmData['AccountId'])\n ->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmData['AccountId']);\n }\n }\n\n $countryCode = $crmData['MailingCountryCode'] ?? null;\n\n // Salesforce allows custom \"countries\" to be created. Disregard these.\n if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {\n $countryCode = null;\n }\n\n // If we have no country code, try to parse it from the country name.\n if ($countryCode === null && empty($crmData['MailingCountry']) === false) {\n $countryCode = $this->convertCountryNameToCode($crmData['MailingCountry']);\n\n if ($countryCode === null && $account) {\n $countryCode = $account->country_code;\n }\n }\n\n $ext = null;\n $parsedNumber = [];\n if (empty($crmData['Phone']) === false) {\n $number = Str::limit($crmData['Phone'], 25, '');\n $parsedNumber = parsePhoneNumber($countryCode, $number);\n\n if (empty($parsedNumber['ext']) === false) {\n $ext = Str::limit($parsedNumber['ext'], 10, '');\n }\n }\n\n $mobileNumber = null;\n if (empty($crmData['MobilePhone']) === false) {\n $mobileNumber = Str::limit(phone_e164($countryCode, $crmData['MobilePhone']), 25, '');\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $data = [\n 'team_id' => $this->team->id,\n 'account_id' => $account->id ?? null,\n 'user_id' => $profile?->user_id,\n 'owner_id' => $crmData['OwnerId'] ?? null,\n 'name' => ($crmData['Name'] ?? null) !== null ? mb_strimwidth($crmData['Name'], 0, 100) : '',\n 'title' => ($crmData['Title'] ?? null) !== null ? mb_strimwidth($crmData['Title'], 0, 128) : null,\n 'email' => ($crmData['Email'] ?? null) !== null ? mb_strimwidth($crmData['Email'], 0, 191) : null,\n 'country_code' => $countryCode,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'ext' => $ext,\n 'mobile_phone' => $mobileNumber,\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['Id'],\n modelType: Contact::class,\n fileName: $crmData['Id'],\n avatarText: $crmData['Name']\n ),\n 'remotely_created_at' => $createdDate,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->contacts(), $crmData['Id']);\n\n /** @var Contact $contact */\n $contact = $this->config->contacts()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n $this->handleObjectDeletion($contact, $crmData);\n\n if ($contact->trashed()) {\n return null;\n }\n\n return $contact;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n $fields = [\n 'InstanceName',\n 'OrganizationType',\n 'IsSandbox',\n ];\n\n $orgValues = $this->getRecord('Organization', $this->config->crm_provider_id, $fields);\n\n $edition = null;\n switch ($orgValues['OrganizationType']) {\n case 'Developer Edition':\n $edition = Configuration::EDITION_DEVELOPER;\n\n break;\n\n case 'Professional Edition':\n $edition = Configuration::EDITION_PROFESSIONAL;\n\n break;\n\n case 'Enterprise Edition':\n $edition = Configuration::EDITION_ENTERPRISE;\n\n break;\n }\n\n $this->config->edition = $edition;\n $this->config->instance = $orgValues['InstanceName'];\n\n // XXX: How can this state be possible?\n if ($this->config->version === null) {\n $this->config->version = Client::MIN_API_VERSION;\n }\n\n $installedVersion = $this->getInstalledAppVersion();\n if ($installedVersion !== null) {\n $installedVersion = (string) $this->getInstalledAppVersion();\n }\n\n $this->config->installed_app_version = $installedVersion;\n\n $this->config->save();\n }\n\n public function getInstalledAppVersion(): ?string\n {\n try {\n $query = '\n SELECT\n SubscriberPackageVersion.MajorVersion,\n SubscriberPackageVersion.MinorVersion,\n SubscriberPackageVersion.PatchVersion,\n SubscriberPackageVersion.BuildNumber\n FROM\n InstalledSubscriberPackage\n WHERE\n SubscriberPackageId = :packageId\n ';\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'packageId' => self::INSTALLED_PACKAGE_ID,\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n // Grab version number.\n $version = $sfField['SubscriberPackageVersion']['MajorVersion'] .\n $sfField['SubscriberPackageVersion']['MinorVersion'] .\n $sfField['SubscriberPackageVersion']['PatchVersion'] .\n $sfField['SubscriberPackageVersion']['BuildNumber'];\n } catch (\\Exception) {\n $version = null;\n }\n\n return $version;\n }\n\n /**\n * Store transcripts as note.\n *\n * @throws \\Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // For SF we also check if Log Notes is enabled.\n if ($this->profile->log_notes === Profile::LOG_NOTE_NONE) {\n return;\n }\n\n if ($activity->opportunity_id && $activity->prospect === null) {\n return;\n }\n\n try {\n $transcriptionData = $this->generateTranscription($activity);\n\n $noteMaxLength = $this->profile->log_notes === Profile::LOG_NOTE_ENHANCED\n ? self::ENHANCED_NOTE_MAX_LENGTH\n : self::CLASSIC_NOTE_MAX_LENGTH;\n\n $title = 'Transcript for ';\n $title .= $activity->title ?? $activity->activity_title;\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $body = mb_strimwidth($transcriptionData, 0, $noteMaxLength);\n\n if ($activity->opportunity_id) {\n $objectId = $activity->opportunity->crm_provider_id;\n } else {\n $objectId = $activity->prospect->crm_provider_id;\n }\n\n $noteId = $this->saveNote($title, $body, $objectId);\n\n // Store crm logged id in transcription.\n $transcription = $activity->getTranscription();\n $transcription->crm_activity_id = $noteId;\n $transcription->save();\n } catch (\\Exception $e) {\n \\Sentry::captureException($e);\n }\n }\n\n public function saveNote(string $title, string $body, string $objectId, ?NoteObject $noteObject = null): ?string\n {\n $noteId = null;\n\n try {\n if ($this->profile->log_notes === Profile::LOG_NOTE_ENHANCED) {\n $noteId = $this->buildEnhancedNote($title, $body, $objectId);\n } else {\n $noteId = $this->buildClassicNote($title, $body, $objectId);\n }\n } catch (HttpNotFoundException $exception) {\n // The profile not having access to create Enhanced Notes. Set their preference to Classic.\n if ($this->profile->log_notes === Profile::LOG_NOTE_ENHANCED) {\n $this->profile->update([\n 'log_notes' => Profile::LOG_NOTE_CLASSIC,\n ]);\n }\n }\n\n return $noteId;\n }\n\n /**\n * This is using the \"Enhanced\" Notes feature, NOT the \"Notes & Attachments\" feature being deprecated.\n *\n * @url https://salesforce.stackexchange.com/questions/104408/how-can-i-create-an-account-note-or-contact-note-via-api-that-is-visible-in-sale\n */\n private function buildEnhancedNote(string $title, string $body, string $objectId): string\n {\n // Decode stored entities, escape HTML (without quoting), then convert line breaks for Salesforce formatting\n $decodedBody = html_entity_decode($body, ENT_QUOTES | ENT_HTML5);\n $sanitizedBody = htmlspecialchars($decodedBody, ENT_NOQUOTES, 'UTF-8', false);\n $content = nl2br($sanitizedBody, false);\n $note = [\n 'OwnerId' => $this->profile->crm_provider_id,\n 'Title' => $title,\n 'Content' => base64_encode($content),\n ];\n\n $noteId = $this->createRecord('ContentNote', $note);\n\n $link = [\n 'ContentDocumentId' => $noteId,\n 'LinkedEntityId' => $objectId,\n 'ShareType' => 'I',\n ];\n\n $this->createRecord('ContentDocumentLink', $link);\n\n return $noteId;\n }\n\n private function buildClassicNote(string $title, string $body, string $objectId): string\n {\n if (in_array($this->parseObjectType($objectId), [Field::OBJECT_TASK, Field::OBJECT_EVENT])) {\n $this->logger->info('[Salesforce] Summary not sent', [\n 'profile_id' => $this->profile->id,\n 'objectId' => $objectId,\n 'reason' => 'Classical Note does not support Task/Event relation',\n ]);\n\n return '';\n }\n\n $titleTrimmed = null;\n\n if (mb_strlen($title) > 80) {\n $titleTrimmed = substr($title, 0, 77) . '...';\n }\n $payload = [\n 'OwnerId' => $this->profile->crm_provider_id,\n 'IsPrivate' => false,\n 'Title' => $titleTrimmed ?? $title,\n 'Body' => $titleTrimmed ? $title . PHP_EOL . $body : $body,\n 'ParentId' => $objectId,\n ];\n\n return $this->createRecord('Note', $payload);\n }\n\n /**\n * @inheritdoc\n */\n public function find(string $name, array $scopes): array\n {\n if ($this->profile === null) {\n return [];\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $limitValues = ['limit' => $this->limit, 'offset' => $this->offset];\n $sosl = $queryBuilder->buildFindQuery($name, $scopes, $limitValues);\n\n $this->logger->info('[Salesforce] Find prospects', [\n 'profile_id' => $this->profile->id,\n 'sosl_query' => $sosl,\n 'search_string' => $name,\n 'scopes' => $scopes,\n ]);\n\n $data = Cache::remember($this->profile->id . $sosl, self::CACHE_TTL, function () use ($sosl) {\n $data = [];\n\n try {\n // Hit remote API.\n $objects = $this->queryHandler->search($sosl);\n\n // Build mapped list.\n foreach ($objects as $object) {\n $type = strtolower($object['attributes']['type']);\n\n $record = [\n 'crmId' => $object['Id'],\n 'name' => $object['Name'],\n 'prospectType' => $type,\n 'phoneNumbers' => [],\n 'crmUrl' => $this->generateProviderUrl($object['Id'], $type),\n ];\n\n switch ($type) {\n case 'lead':\n if (empty($object['Company']) === false) {\n $record['organization'] = $object['Company'];\n }\n\n if (empty($object['Title']) === false) {\n $record['title'] = $object['Title'];\n }\n\n $stage = $this->config->stages()\n ->where('type', Stage::TYPE_LEAD)\n ->where('name', $object['Status'])\n ->first();\n\n // Lazy create the stage.\n if ($stage === null) {\n $stage = $this->importStages([Stage::TYPE_LEAD], $object['Status']);\n }\n\n if ($stage) {\n $record += [\n 'stage' => [\n 'id' => $stage->id_string,\n 'name' => $stage->name,\n ],\n ];\n }\n\n if (empty($object['RecordTypeId']) === false) {\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $object['RecordTypeId'])\n ->first();\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n }\n\n break;\n\n case 'account':\n if (empty($object['Industry']) === false) {\n $record['industry'] = $object['Industry'];\n $record['detailsLine'] = $object['Industry'];\n }\n if (! empty($object['PersonEmail'])) {\n $record['detailsLine'] = $object['PersonEmail'];\n }\n\n break;\n\n case 'contact':\n // For contacts, we should try and fetch their account name too.\n if ($object['AccountId']) {\n // Cheaper to get this locally.\n $account = $this->config->accounts()\n ->where('crm_provider_id', $object['AccountId'])\n ->first(['name']);\n\n if ($account) {\n $record['organization'] = $account->name;\n }\n }\n\n if (! empty($object['IsPersonAccount']) && $object['Email']) {\n $record['detailsLine'] = $object['Email'];\n } else {\n if (empty($object['Title']) === false) {\n $record['title'] = $object['Title'];\n }\n }\n\n break;\n }\n\n // Add phone numbers to record.\n if (empty($object['Phone']) === false && $object['Phone']) {\n $record['phoneNumbers'][] = [\n 'number' => $object['Phone'],\n 'nationalFormat' => phone_national($this->profile->user->country_code, $object['Phone']),\n 'type' => 'phone',\n ];\n }\n\n if (empty($object['MobilePhone']) === false && $object['MobilePhone']) {\n $record['phoneNumbers'][] = [\n 'number' => $object['MobilePhone'],\n 'nationalFormat' => phone_national(\n $this->profile->user->country_code,\n $object['MobilePhone']\n ),\n 'type' => 'mobile',\n ];\n }\n\n $data[] = $record;\n }\n } catch (NoResultsException $e) {\n $data = [];\n }\n\n return $data;\n });\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n\n if ($crmAccountId === null) {\n return $data;\n }\n\n if ($userId) {\n $profileRepository = app(ProfileRepository::class);\n $profile = $profileRepository->findProfileByUserId($this->config, $userId);\n\n $ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;\n }\n\n try {\n // Perhaps their profile has no opportunity permissions.\n if ($this->profile === null || $this->profile->opportunity_fields === null) {\n return $data;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $query = $queryBuilder->buildFindOpportunitiesQuery();\n\n $objects = $this->queryHandler->query($query, ['accountId' => $crmAccountId]);\n\n foreach ($objects as $object) {\n $record = [\n 'crmId' => $object['Id'],\n 'name' => $object['Name'],\n 'won' => $object['IsWon'],\n 'closed' => $object['IsClosed'],\n ];\n\n $valueFieldName = 'Amount';\n if ($this->config->opportunity_value_field_id) {\n $valueFieldName = $this->config->opportunityValueField->crm_provider_id;\n }\n\n if (empty($object[$valueFieldName]) === false) {\n $currency = $object['CurrencyIsoCode'] ?? $this->config->default_currency;\n $value = formatCurrency($object[$valueFieldName], $currency);\n\n $record += [\n 'value' => $value,\n ];\n }\n\n $stage = $this->config->stages()\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->where('name', $object['StageName'])\n ->first();\n\n // Lazy create the stage.\n if ($stage === null) {\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $object['StageName']);\n }\n\n $record += [\n 'stage' => [\n 'id' => $stage->id_string,\n 'name' => $stage->name,\n ],\n ];\n\n if (empty($object['RecordTypeId']) === false) {\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $object['RecordTypeId'])\n ->first();\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n }\n\n if ($ownerId && isset($object['OwnerId']) && $object['OwnerId'] === $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n } catch (NoResultsException $e) {\n return $data;\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n public function getContactRolesFromCrm(?Carbon $since = null): array\n {\n $roles = [];\n\n if ($this->profile === null) {\n return $roles;\n }\n\n $queryBuilder = app(QueryBuilder::class, ['profile' => $this->profile]);\n\n $query = $queryBuilder->buildGetContactRolesQuery($since);\n\n try {\n $objects = $this->queryHandler->query($query);\n\n foreach ($objects as $object) {\n $roles[] = [\n 'id' => $object['Id'],\n 'contactId' => $object['ContactId'],\n 'opportunityId' => $object['OpportunityId'],\n 'ownerId' => $object['Opportunity']['OwnerId'] ?? null,\n 'isPrimary' => $object['IsPrimary'],\n 'role' => $object['Role'],\n ];\n }\n } catch (NoResultsException) {\n // Just return an empty array.\n $this->logger->info('[Salesforce] No contact roles found', [\n 'since' => $since?->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n }\n\n return $roles;\n }\n\n public function syncContactRoles(Carbon $since): int\n {\n $contactRoleRepository = app(ContactRoleRepository::class);\n\n $crmContactRoles = $this->getContactRolesFromCrm(since: $since);\n $syncCount = 0;\n $contactRoles = [];\n\n foreach ($crmContactRoles as $crmContactRole) {\n $contactRoles[] = $this->importContactRole($crmContactRole);\n $syncCount++;\n }\n\n $contactRoleRepository->saveContactRoles($contactRoles);\n\n $this->syncRemotelyDeletedContactRoles();\n\n return $syncCount;\n }\n\n private function importContactRole(array $contactRole): array\n {\n $contact = $this->config->contacts()\n ->where('crm_provider_id', $contactRole['contactId'])\n ->first();\n\n if ($contact === null) {\n $contact = $this->syncContact($contactRole['contactId']);\n }\n\n $opportunity = $this->config->opportunities()\n ->where('crm_provider_id', $contactRole['opportunityId'])\n ->first();\n\n if ($opportunity === null) {\n $opportunity = $this->syncOpportunity($contactRole['opportunityId']);\n }\n\n $role = null;\n if (! empty($contactRole['role'])) {\n $role = mb_strimwidth($contactRole['role'], 0, 191);\n }\n\n return [\n 'crm_configuration_id' => $this->config->getId(),\n 'contact_id' => $contact->getId(),\n 'crm_provider_id' => $contactRole['id'],\n 'subject_type' => ContactRole::SUBJECT_TYPE_OPPORTUNITY,\n 'subject_id' => $opportunity->getId(),\n 'is_primary' => $contactRole['isPrimary'],\n 'role' => $role,\n ];\n }\n\n protected function syncRemotelyDeletedContactRoles(): bool\n {\n try {\n $deletedRemotely = $this->queryHandler->queryDeleted('OpportunityContactRole');\n } catch (NoResultsException $e) {\n return false;\n }\n\n $deletedOpportunities = $deletedRemotely->getResults();\n $deletedIds = array_column($deletedOpportunities, 'id');\n\n $contactRoleRepository = app(ContactRoleRepository::class);\n\n foreach (array_chunk($deletedIds, self::HARD_DELETE_CHUNK) as $chunk) {\n $contactRoleRepository->deleteContactRoles($chunk);\n\n $this->logger->info('[' . $this->getDisplayName() . '] Remotely deleted opportunities synced', [\n 'teamId' => $this->team->id_string,\n 'remotelyDeletedOpportunities' => $chunk,\n 'count' => count($chunk),\n ]);\n }\n\n return true;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = $objects = [];\n\n $hasWho = \\in_array($objectType, ['lead', 'contact']);\n $playbook = $this->getPlaybook($this->profile->user);\n $fields = array_merge(\n $this->profile->getFieldsAsArray(parent::OBJECT_TASK),\n $playbook && $playbook->activityField ? [$playbook->activityField->crm_provider_id] : []\n );\n\n // Query should default to any open call for that user.\n $query = '\n SELECT ' . implode(',', array_unique($fields)) . '\n FROM Task\n WHERE OwnerId = :ownerId\n AND IsArchived = false\n AND IsDeleted = false\n AND IsClosed = false\n AND (';\n\n if ($objectType === 'account') {\n // This covers tasks tied to a related contact or opportunity too.\n $query .= '\n AccountId = :accountId';\n }\n\n if ($hasWho) {\n $query .= '\n WhoId = :whoId';\n\n // If we are also going to check on a specific opportunity, set that up.\n if ($opportunityId) {\n $query .= ' OR WhatId = :whatId';\n }\n }\n\n $query .= ' ) ORDER BY LastModifiedDate DESC';\n\n try {\n $objects = $this->queryHandler->query($query, [\n 'ownerId' => $this->profile->crm_provider_id,\n 'whoId' => $objectId,\n 'whatId' => $opportunityId,\n 'accountId' => $objectId,\n ]);\n } catch (NoResultsException $e) {\n return $data;\n } finally {\n $this->logger->debug(sprintf('[Salesforce] Found %s tasks for query \"%s\"', count($objects), $query));\n }\n\n foreach ($objects as $object) {\n $dueDate = $object['ActivityDate'] ? Carbon::parse($object['ActivityDate'])->toIso8601String() : null;\n $data[] = [\n 'crmId' => $object['Id'],\n 'subject' => $object['Subject'],\n 'due' => $dueDate,\n 'type' => $object[$playbook->activityField->crm_provider_id],\n ];\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getEvents(string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = $objects = [];\n $user = $this->profile?->user;\n if ($this->profile === null || $user === null) {\n return $data;\n }\n\n $hasWho = \\in_array($objectType, ['lead', 'contact']);\n $playbook = $this->getPlaybook($user);\n $fields = array_merge(\n $this->profile->getFieldsAsArray(parent::OBJECT_EVENT),\n $playbook && $playbook->activityField ? [$playbook->activityField->crm_provider_id] : []\n );\n\n // Query should default to any event starting in the last week and ending up until today owned by the user.\n $query = '\n SELECT ' . implode(',', array_unique($fields)) . '\n FROM Event\n WHERE OwnerId = :ownerId\n AND IsArchived = false\n AND IsAllDayEvent = false\n AND StartDateTime >= LAST_N_DAYS:7\n AND EndDateTime <= TODAY\n AND (';\n\n if ($objectType === 'account') {\n // This covers events tied to a related contact or opportunity too.\n $query .= '\n AccountId = :accountId';\n }\n\n if ($hasWho) {\n $query .= '\n WhoId = :whoId';\n\n // If we are also going to check on a specific opportunity, set that up.\n if ($opportunityId) {\n $query .= ' OR WhatId = :whatId';\n }\n }\n\n $query .= ' ) ORDER BY LastModifiedDate DESC';\n\n try {\n $objects = $this->queryHandler->query($query, [\n 'ownerId' => $this->profile->crm_provider_id,\n 'whoId' => $objectId,\n 'whatId' => $opportunityId,\n 'accountId' => $objectId,\n ]);\n } catch (NoResultsException $e) {\n return $data;\n } finally {\n $this->logger->debug(sprintf('[Salesforce] Found %s tasks for query \"%s\"', count($objects), $query));\n }\n\n foreach ($objects as $object) {\n $dueDate = $object['StartDateTime'] ? Carbon::parse($object['StartDateTime'])->toIso8601String() : null;\n\n $data[] = [\n 'crmId' => $object['Id'],\n 'subject' => $object['Subject'],\n 'due' => $dueDate,\n 'type' => $object[$playbook->activityField->crm_provider_id],\n ];\n }\n\n return $data;\n }\n\n /**\n * Try to find CRM Objects using email address\n *\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchExactlyByEmail(string $email, ?int $userId = null): ?array\n {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $sosl = $queryBuilder->buildMatchByQuery($email, Field::TYPE_EMAIL);\n if ($sosl === null) {\n return null;\n }\n\n try {\n $objects = $this->queryHandler->search($sosl);\n $objects = $this->queryHandler->prioritiseResults(\n $objects,\n $email,\n QueryHandler::PRIORITISE_EMAIL\n );\n\n $data = $this->convertCrmData($objects, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (NoResultsException $e) {\n // Try the account next.\n if ($this->profile->account_fields === null) {\n return null;\n }\n }\n\n return null;\n }\n\n public function getDomain(string $email): ?string\n {\n // SF improved search - strip the domain extension, min domain name length 4\n return $this->getCompanyNameFromEmail(email: $email, minNameLength: 4);\n }\n\n /**\n * Try to find CRM objects using domain name of the email address\n *\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByDomain(string $domain, ?int $userId = null): ?array\n {\n $companyName = $domain;\n\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $sosl = $queryBuilder->buildMatchByDomainQuery($companyName);\n\n try {\n $objects = $this->queryHandler->search($sosl);\n\n $data = $this->convertCrmData($objects, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (NoResultsException) {\n return null;\n }\n }\n\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n // Don't bother looking up numbers that are masked.\n if (str_contains($phone, '**')) {\n return null;\n }\n\n if ($this->isPhoneNumberOfTeamMember($phone)) {\n return null;\n }\n\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $phoneNational = phone_national(null, $phone) ?? '';\n $possiblePhoneFormats = collect([\n preg_replace('/\\D/', '', ltrim($phone, '0+')),\n preg_replace('/\\D/', '', $phoneNational),\n formatDashPhoneNumber($phone),\n $phoneNational,\n ])\n ->filter() // Removes null and empty strings\n ->unique()\n ->values();\n\n foreach ($possiblePhoneFormats as $phone) {\n $sosl = $queryBuilder->buildMatchByQuery($phone, Field::TYPE_PHONE);\n if ($sosl === null) {\n continue;\n }\n\n try {\n $objects = $this->queryHandler->search($sosl);\n $objects = $this->queryHandler->prioritiseResults(\n $objects,\n $phone,\n QueryHandler::PRIORITISE_PHONE\n );\n\n return $this->convertCrmData($objects, $userId);\n } catch (NoResultsException) {\n continue;\n }\n }\n\n return null;\n }\n\n private function isPhoneNumberOfTeamMember(string $phone): bool\n {\n $teamRepository = app(TeamRepository::class);\n $user = $teamRepository->findTeamMemberByPhone($this->team, $phone);\n\n if ($user instanceof User) {\n return true;\n }\n\n return false;\n }\n\n protected function getCacheKey(string $object, ?int $userId = null): ?string\n {\n $key = $this->profile->id . $object;\n $keySuffix = $this->getOwnerKeySuffix($userId);\n\n return $key . $keySuffix;\n }\n\n private function getOwnerKeySuffix(?int $userId = null): string\n {\n return $userId === null ? '' : (string) $userId;\n }\n\n /** Determine the CRM Objects which represent the call activity. */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n if ($this->profile === null) {\n return null;\n }\n\n $cacheKey = $this->getCacheKey($name, $userId);\n\n $result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $sosl = $queryBuilder->buildMatchByQuery($name, 'name');\n if ($sosl === null) {\n return false;\n }\n\n try {\n $objects = $this->queryHandler->search($sosl);\n } catch (NoResultsException $e) {\n return false;\n }\n\n $objects = $this->queryHandler->prioritiseResults(\n $objects,\n $name,\n QueryHandler::PRIORITISE_NAME\n );\n\n $data = $this->convertCrmData($objects, $userId);\n\n return (! empty(array_filter($data))) ? $data : false;\n });\n\n return is_array($result) ? $result : null;\n }\n\n /**\n * @return array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n protected function convertCrmData(QueryIterator $objects, ?int $userId = null): array\n {\n $lead = null;\n $contact = null;\n $opportunity = null;\n $account = null;\n $stage = null;\n $countryCode = null;\n\n if ($objects->count() > 0) {\n $object = $objects->current();\n\n if ($object['attributes']['type'] === 'Lead') {\n $lead = $this->importLead($object);\n\n // Lead might not be imported if the Stage is null for example.\n if ($lead) {\n $countryCode = $lead->country_code;\n $stage = $lead->stage;\n }\n } else {\n if ($object['attributes']['type'] === 'Contact') {\n $contact = $this->importContact($object);\n $account = $contact->account;\n } else {\n $account = $this->importAccount($object);\n }\n\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account) {\n $countryCode = $account->country_code;\n }\n\n try {\n $sfOpportunities = $this->findOpportunities(\n $account?->getCrmProviderId(),\n $contact?->getCrmProviderId(),\n $userId\n );\n\n // Take the first opportunity, which will be ordered as priority based on their settings.\n if (! empty($sfOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($sfOpportunities[0]['crmId']);\n $stage = $opportunity?->stage;\n }\n } catch (Exception) {\n // Nothing to see here.\n }\n }\n }\n\n return [\n $lead,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n /**\n * @inheritdoc\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n if ($stage->type === Stage::TYPE_LEAD) {\n $objectType = 'Lead';\n $objectId = $crmObject->crm_provider_id;\n $objectStageType = 'Status';\n } else {\n $objectType = 'Opportunity';\n $objectId = $crmObject->crm_provider_id;\n $objectStageType = 'StageName';\n }\n\n $headers = [];\n if ($this->config->trigger_assignment_rules === false) {\n // @see: https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/headers_autoassign.htm\n $headers = [\n 'Sforce-Auto-Assign' => 'false',\n ];\n }\n\n $this->updateRecord($objectType, $objectId, [$objectStageType => $stage->name], $headers);\n }\n\n public function parseObjectType(string $objectId): string\n {\n if (Str::startsWith($objectId, '001')) {\n return 'account';\n }\n\n if (Str::startsWith($objectId, '003')) {\n return 'contact';\n }\n\n if (Str::startsWith($objectId, '00Q')) {\n return 'lead';\n }\n\n if (Str::startsWith($objectId, '006')) {\n return 'opportunity';\n }\n\n if (Str::startsWith($objectId, '00U')) {\n return 'event';\n }\n\n if (Str::startsWith($objectId, '00T')) {\n return 'task';\n }\n\n throw new \\InvalidArgumentException('Unsupported Object Type');\n }\n\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, ['profile' => $this->profile]);\n $query = $queryBuilder->buildGetUsersQuery($userToSearch);\n\n try {\n $salesforceUsers = $this->queryHandler->query($query, [\n 'active' => true,\n ]);\n } catch (NoResultsException $e) {\n $this->logger->info('[Salesforce] Sync Profiles. No users found', [\n 'query' => $query,\n 'error' => $e->getMessage(),\n ]);\n\n return null;\n }\n\n $teamRepository = app(TeamRepository::class);\n $customRules = $this->getCustomProfileRules($teamRepository);\n\n foreach ($salesforceUsers as $crmUser) {\n if ($crmUser['Email'] === null) {\n continue;\n }\n\n if (! $this->customProfileValidation($crmUser, $customRules)) {\n continue;\n }\n\n $user = $teamRepository->findActiveTeamMemberByEmail($this->team, $crmUser['Email']);\n\n if (! $user instanceof User) {\n continue;\n }\n\n $edition = $crmUser['UserPreferencesLightningExperiencePreferred']\n ? Profile::EDITION_LIGHTNING\n : Profile::EDITION_CLASSIC;\n\n $profileRepository = app(ProfileRepository::class);\n $profile = $profileRepository->updateOrCreateProfile(\n $user,\n [\n 'crm_configuration_id' => $this->config->getId(),\n 'crm_provider_id' => $crmUser['Id'],\n ],\n [\n 'user_id' => $user->getId(),\n 'edition' => $edition,\n 'has_external_cti' => ! empty($crmUser['CallCenterId']),\n 'crm_profile_id' => $crmUser['ProfileId'],\n ]\n );\n\n if ($userToSearch instanceof User && $userToSearch->getId() === $user->getId()) {\n return $profile;\n }\n }\n\n // Clean up inactive profiles\n try {\n $this->archiveInactiveProfiles();\n } catch (\\Exception $e) {\n $this->logger->warning('[Salesforce] Profile archiving failed', [\n 'teamId' => $this->team->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $url = null;\n\n // For Salesforce it's easy, we just point every object to the apex domain and they handle it.\n switch ($objectType) {\n case 'lead':\n case 'account':\n case 'contact':\n case 'opportunity':\n case 'task':\n case 'event':\n case 'activity':\n\n $url = $this->config->crm_base_url . '/' . $providerId;\n\n break;\n }\n\n return $url;\n }\n\n public function buildTaskSearchFields(): array\n {\n return ['Id', 'WhoId', 'WhatId', 'AccountId'];\n }\n\n public function getTaskByFilterConditions(\n array $fields,\n array $filters,\n bool $bulkSearch = false,\n bool $strictFilters = true\n ): ?array {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $query = $queryBuilder->buildSearchTaskQuery($fields, $filters, $bulkSearch, $strictFilters);\n\n try {\n if (! $bulkSearch) {\n $objects = $this->queryHandler->query($query, $filters);\n if ($objects->count() === 1) {\n return $objects->current();\n }\n }\n\n if ($bulkSearch) {\n $objects = $this->queryHandler->query($query);\n $records = [];\n foreach ($objects as $record) {\n $key = $record[end($fields)];\n $records[$key] = $record;\n }\n\n return $records;\n }\n } catch (\\Exception $e) {\n $this->logger->info('[Salesforce] Failed to execute query', [\n 'query' => $query,\n 'error' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function mapCrmObjects(array $task): array\n {\n $activityData = [];\n\n if (! empty($task['WhoId'])) {\n $type = $this->parseObjectType($task['WhoId']);\n $activityData[$type] = $task['WhoId'];\n }\n if (! empty($task['AccountId'])) {\n $activityData['account'] = $task['AccountId'];\n }\n if (! empty($task['WhatId'])) {\n $activityData['opportunity'] = $task['WhatId'];\n }\n\n return $activityData;\n }\n\n /**\n * Get SF task by Outreach call id.\n */\n public function getTaskByFilter(\n string $activityFieldType,\n array $filters,\n string $operator = '=',\n array $additionalFields = []\n ): ?array {\n $data = [];\n\n try {\n // Default (base) fields.\n $fields = ['Id', 'Subject', 'Description', 'ActivityDate', 'WhoId', 'WhatId', $activityFieldType];\n\n foreach ($additionalFields as $additionalField) {\n $fields[] = $additionalField->crm_provider_id;\n }\n\n $fields = array_unique($fields);\n\n // Find task with the same Outreach id as the call id.\n $query = 'SELECT ' . implode(',', $fields) . '\n FROM Task\n WHERE IsArchived = false AND IsDeleted = false';\n\n foreach ($filters as $key => $value) {\n $key = preg_quote($key, '/');\n $key = str_replace(['\\'', '\"'], '', $key);\n // Prepare the substitution.\n $strKey = \":$key\";\n\n $query .= \" AND $key $operator $strKey\";\n }\n\n $query .= ' ORDER BY LastModifiedDate DESC LIMIT 1';\n\n $objects = $this->queryHandler->query($query, $filters);\n\n // There should be only one task related to this call if any.\n if ($objects->count() === 1) {\n $object = $objects->current();\n\n $dueDate = $object['ActivityDate'] ? Carbon::parse($object['ActivityDate'])->toIso8601String() : null;\n\n $data = array_merge($object, [\n 'crmId' => $object['Id'],\n 'subject' => $object['Subject'],\n 'summary' => $object['Description'],\n 'due' => $dueDate,\n 'Type' => $object[$activityFieldType],\n ]);\n }\n } catch (NoResultsException $e) {\n // Filters don't match any records.\n } catch (ServiceUnavailableException $serviceUnavailableException) {\n // Service cannot be queried. We should probably log this.\n }\n\n return $data;\n }\n\n /**\n * Get Salesforce fields including datetime fields\n *\n * @param $objectType\n */\n private function getAllFieldsAsArray($objectType): array\n {\n $basicFields = [];\n // Not all users have access to all object fields.\n if ($this->profile->{$objectType . '_fields'}) {\n $basicFields = explode(',', $this->profile->{$objectType . '_fields'});\n }\n\n $extraFields = [\n 'CreatedDate',\n 'LastModifiedDate',\n 'IsDeleted',\n ];\n\n if ($objectType === self::OBJECT_OPPORTUNITY\n && $this->config->opportunity_value_field_id\n && ! in_array($this->config->opportunityValueField->crm_provider_id, $basicFields)\n ) {\n $extraFields[] = $this->config->opportunityValueField->crm_provider_id;\n }\n\n return array_unique(array_merge($basicFields, $extraFields));\n }\n\n /**\n * Generate transcription for activity description.\n */\n private function generateTranscription(Activity $activity): string\n {\n if (! ($this->config->store_transcript)) {\n // If sending transcription to activity toggle is disabled\n return '';\n }\n\n return $this->transcriptionService\n ->findTranscriptionByActivity($activity)\n ->map(static function (array $transcriptionSegment): string {\n return $transcriptionSegment['formattedStartsAt'] . ' | ' . $transcriptionSegment['transcript'];\n })\n ->implode(PHP_EOL);\n }\n\n /**\n * Find related Salesforce event based on activity data\n *\n * @return array<string>\n */\n public function fetchRelatedActivity(Activity $activity): array\n {\n $this->logger->info('[Salesforce] Searching for related activity', [\n 'activityId' => $activity->getUuid(),\n 'ownerId' => $this->profile?->crm_provider_id,\n ]);\n\n $sfEvent = $this->fetchRelatedEvent($activity);\n if (empty($sfEvent)) {\n $this->logger->info('[Salesforce] No related activity found', [\n 'activityId' => $activity->getUuid(),\n 'ownerId' => $this->profile?->crm_provider_id,\n 'account' => $activity->hasAccount()\n ? $activity->getAccount()->getCrmProviderId()\n : null,\n ]);\n\n return [];\n }\n\n return $sfEvent;\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n if ($activity->isTypeConference() === false) {\n return null;\n }\n\n if ($activity->hasActualStartTime() === false && $activity->hasScheduledStartTime() === false) {\n return null;\n }\n\n if (! $activity->hasProspect()) {\n $this->logger->info('[Salesforce] Skip look up, Activity not linked to Lead, Contact or Account', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n $playbook = $this->getPlaybook($activity->getUser());\n if ($playbook !== null && $playbook->getActivityType() === Playbook::ACTIVITY_TYPE_TASK) {\n $this->logger->info('[Salesforce] Skip auto-sync for task-based playbook', [\n 'activityUuid' => $activity->getUuid(),\n 'playbookId' => $playbook->getId(),\n 'playbookType' => $playbook->getActivityType(),\n ]);\n\n return null;\n }\n\n try {\n $sfEvent = $this->fetchRelatedActivity($activity);\n if (empty($sfEvent)) {\n return null;\n }\n\n [$activityField, $activityType] = $this->resolveActivityTypeFromEvent($activity, $sfEvent);\n\n $this->logger->info('[Salesforce] Found related activity', [\n 'activityId' => $activity->getUuid(),\n 'sfEvent' => $sfEvent['Id'],\n 'activityFieldName' => $activityField,\n 'crmActivityType' => ($activityField !== null && isset($sfEvent[$activityField]))\n ? $sfEvent[$activityField]\n : null,\n 'activityType' => $activityType,\n ]);\n\n $userId = $this->findRelatedActivityUserId($activity, $sfEvent);\n\n if ($activity->getUserId() !== $userId) {\n $this->logger->info('[Salesforce] Updating meeting owner', [\n 'activityId' => $activity->getUuid(),\n 'oldUserId' => $activity->getUserId(),\n 'newUserId' => $userId,\n ]);\n }\n\n $this->updateSfEventDescription($activity, $sfEvent);\n\n $activity->update([\n 'user_id' => $userId,\n 'crm_provider_id' => $sfEvent['Id'],\n 'playbook_category_id' => $activityType->id ?? $activity->getCategory()?->getId(),\n ]);\n\n $this->logger->info('[Salesforce] Activity updated', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return $activity;\n } catch (\\Exception $exception) {\n \\Sentry::captureException($exception);\n\n throw $exception;\n }\n }\n\n /**\n * @param array<string, mixed> $sfEvent\n *\n * @return array{0: string|null, 1: mixed}\n */\n private function resolveActivityTypeFromEvent(Activity $activity, array $sfEvent): array\n {\n $activityField = $this->getActivityFieldName($activity);\n $activityType = null;\n\n if ($activityField !== null && ! empty($sfEvent[$activityField])) {\n $playbook = $this->getPlaybook($activity->getUser());\n $activityType = $this->getPlaybookCategory($playbook, strval($sfEvent[$activityField]));\n }\n\n return [$activityField, $activityType];\n }\n\n /**\n * @param array<string> $sfEvent\n */\n private function findRelatedActivityUserId(Activity $activity, array $sfEvent): int\n {\n $userId = $activity->getUserId();\n\n if (empty($sfEvent['OwnerId']) === false) {\n $profile = $this\n ->config\n ->profiles()\n ->where('crm_provider_id', $sfEvent['OwnerId'])\n ->get()\n ->filter(static function (Profile $profile) use ($activity): bool {\n if (! $activity->isTypeConference()) {\n return ! empty($profile->user) ? $profile->user->isStatusActive() : false;\n }\n\n $participants = $activity->getParticipants();\n\n return ! empty($profile->user)\n ? $profile->user->isStatusActive()\n && $profile->user->hasPermission(PermissionEnum::RECORD_MEETING)\n && $participants->contains('user_id', $profile->user_id)\n : false;\n })\n ->first();\n\n if ($profile) {\n $userId = $profile->user_id;\n }\n }\n\n return $userId;\n }\n\n /**\n * @param array<string, mixed> $sfEvent\n */\n private function updateSfEventDescription(Activity $activity, array $sfEvent): void\n {\n try {\n if (str_contains($sfEvent['Description'], $activity->id_string)) {\n return;\n }\n\n $payload = [\n 'Description' => $sfEvent['Description']\n . PHP_EOL\n . PHP_EOL\n . (new DecorateActivity())->generateDescription($activity),\n ];\n\n $this->logger->info('[Salesforce] Update record', [\n 'activityId' => $activity->getUuid(),\n 'sfEvent' => $sfEvent['Id'],\n 'payload' => $payload,\n ]);\n\n $payload = array_merge(\n $payload,\n $this->payloadBuilder->fetchCustomFieldData($activity, Field::OBJECT_EVENT)\n );\n\n $this->updateRecord('Event', $sfEvent['Id'], $payload);\n } catch (\\Exception) {\n $this->logger->error('[Salesforce] Failed to update record', [\n 'activityUuid' => $activity->getUuid(),\n 'sfEvent' => $sfEvent['Id'],\n ]);\n }\n }\n\n /**\n * Returns the most recently modified Event within time range (if any).\n *\n * @return array|null An Event record from Salesforce.\n */\n private function fetchRelatedEvent(Activity $activity): ?array\n {\n $ownerId = $this->profile?->crm_provider_id;\n if ($ownerId === null) {\n return [];\n }\n\n /** @var ?Carbon $from */\n /** @var ?Carbon $to */\n [$from, $to] = $this->getFromToDates($activity);\n\n try {\n $whoId = null;\n $hasWho = $activity->lead_id || $activity->contact_id;\n if ($hasWho) {\n $whoId = $activity->hasLead()\n ? $activity->getLead()->crm_provider_id\n : $activity->getContact()->crm_provider_id;\n }\n\n if ($hasWho === false && $activity->account_id === null) {\n return null;\n }\n\n $query = $this->buildFetchRelatedEventQuery($activity);\n\n $objects = $this->queryHandler->query($query, [\n 'ownerId' => $ownerId,\n 'whoId' => $whoId,\n 'whatId' => $activity->hasOpportunity() ? $activity->getOpportunity()->crm_provider_id : null,\n 'accountId' => $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null,\n 'from' => $from?->format('Y-m-d\\TH:i:s\\Z'),\n 'to' => $to?->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($objects as $object) {\n return $object;\n }\n } catch (NoResultsException $e) {\n return [];\n }\n\n return [];\n }\n\n private function getFromToDates(Activity $activity): array\n {\n $from = null;\n $to = null;\n\n /** @var ?CalendarEvent $calendarEvent */\n $calendarEvent = $activity->calendarEvent()->first();\n if ($calendarEvent !== null) {\n $from = $calendarEvent->getStartTime();\n $to = $calendarEvent->getEndTime();\n }\n\n // For non-calendar imported activities\n // Also double check if calendar event dates could be null?\n // If null use what we've got so far\n if ($from === null || $to === null) {\n $from = $activity->hasScheduledStartTime()\n ? $activity->getScheduledStartTime()\n : $activity->getActualStartTime();\n $to = $activity->hasScheduledEndTime()\n ? $activity->getScheduledEndTime()->addMinutes(15)\n : $activity->getActualEndTime();\n }\n\n return [$from, $to];\n }\n\n /**\n * Determines the appropriate activity field name for querying Salesforce events.\n *\n * This method follows a hierarchy to determine the field name:\n * 1. Uses the playbook's activity field if it exists and is in the profile's accessible fields\n * 2. Falls back to the default activity field if the profile has no event fields configured\n * 3. Returns null if no suitable field is found\n *\n * @param Activity $activity The activity to determine the field for\n *\n * @return string|null The field name to use in queries, or null if none is available\n */\n private function getActivityFieldName(Activity $activity): ?string\n {\n if ($this->profile === null) {\n $this->logger->warning('[Salesforce] Cannot determine activity field - profile not found', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n $profileEventFields = $this->profile->getFieldsAsArray('event');\n\n if (empty($profileEventFields)) {\n $defaultActivityField = $this->getDefaultActivityField(Field::OBJECT_EVENT);\n $defaultFieldName = $defaultActivityField?->getAttribute('crm_provider_id');\n // Profile not yet synced — fall back to the default activity field.\n // There is a small chance that the profile won't have Default Activity Type field access\n // in which case the query will fail.\n // This is however an edge case and should be reviewed for profile sync issues.\n Sentry::withScope(function (\\Sentry\\State\\Scope $scope) use ($defaultFieldName): void {\n $scope->setContext('details', [\n 'profileId' => $this->profile->id,\n 'defaultField' => $defaultFieldName,\n ]);\n Sentry::captureMessage(\n '[Salesforce] Profile event fields empty, falling back to default activity field.',\n \\Sentry\\Severity::warning()\n );\n });\n\n return $defaultFieldName;\n }\n\n $playbook = $this->getPlaybook($activity->getUser());\n\n if (! is_null($playbook) && ! is_null($playbook->getActivityField())) {\n $playbookFieldName = $playbook->getActivityField()->getAttribute('crm_provider_id');\n\n if (in_array($playbookFieldName, $profileEventFields, true)) {\n return $playbookFieldName;\n }\n\n $this->logger->warning('[Salesforce] Playbook activity field not found in profile fields', [\n 'activityId' => $activity->getUuid(),\n 'playbookField' => $playbookFieldName,\n 'profileId' => $this->profile->id,\n ]);\n }\n\n return null;\n }\n\n private function buildFetchRelatedEventQuery(Activity $activity): string\n {\n $hasWho = $activity->lead_id || $activity->contact_id;\n\n $activityFieldName = $this->getActivityFieldName($activity);\n $fields = array_filter(['Id', 'Description', 'OwnerId', $activityFieldName]);\n\n $ownerCondition = '(OwnerId = :ownerId OR CreatedById = :ownerId)';\n\n $query = '\n SELECT ' . implode(',', $fields) . '\n FROM Event\n WHERE ' . $ownerCondition . '\n AND IsArchived = false\n AND IsAllDayEvent = false\n AND StartDateTime >= :from\n AND EndDateTime <= :to\n AND (';\n\n $operator = '';\n if ($activity->account_id) {\n // This covers events tied to a related contact or opportunity too.\n $query .= 'AccountId = :accountId';\n\n $operator = ' OR ';\n }\n\n if ($hasWho) {\n $query .= $operator . 'WhoId = :whoId';\n\n // If we are also going to check on a specific opportunity, set that up.\n if ($activity->opportunity_id) {\n $query .= ' OR WhatId = :whatId';\n }\n }\n\n $query .= ') ORDER BY LastModifiedDate DESC';\n\n return $query;\n }\n\n public function fetchProspect(array $task): array\n {\n $lead = $account = $opportunity = $contact = $stage = $countryCode = null;\n $externalId = $task['WhoId'] ?? null;\n\n // Lead or Contact\n if ($externalId) {\n try {\n [$lead, $account, $opportunity, $contact, $stage, $countryCode] = $this->parseRecords($externalId);\n } catch (\\InvalidArgumentException $exception) {\n // Invalid object type.\n }\n }\n\n // If we happen to know the opportunity or account from the Task, figure that out.\n if (empty($task['WhatId']) === false) {\n // WhatId could be either Account ID or Opportunity ID.\n // If WhatId is Opportunity ID, get the opportunity and stage from the CRM.\n try {\n [, $account, $opportunity, , $stage, ] = $this->parseRecords($task['WhatId']);\n } catch (\\InvalidArgumentException $exception) {\n // Invalid object type.\n }\n }\n\n return [$lead, $account, $opportunity, $contact, $stage, $countryCode];\n }\n\n /**\n * Save activity transcription summary as note\n */\n public function saveTranscriptionSummaryAsNote(\n ActivityContract $activity,\n string $title,\n string $body,\n ?string $objectId,\n ?NoteObject $noteObject = null,\n ): ?string {\n return $this->saveNote($title, $body, (string) $objectId);\n }\n\n public function getObjectByFilterConditions(string $objectType, array $fields, array $filters): ?array\n {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $query = $queryBuilder->buildObjectSearchQuery($objectType, $fields, $filters);\n\n try {\n $objects = $this->queryHandler->query($query, $filters);\n if ($objects->count() === 1) {\n return $objects->current();\n }\n } catch (\\Exception $e) {\n $this->logger->info('[Salesforce] Failed to execute query', [\n 'query' => $query,\n 'error' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n private function getCustomProfileRules(TeamRepository $teamRepository): array\n {\n $teamSettings = $teamRepository->getTeamSetting($this->team, 'custom_profile_validation');\n\n if ($teamSettings instanceof TeamSettings && $teamSettings->getValueType() === 'array') {\n $customRules = json_decode($teamSettings->getValue(), true);\n if (is_array($customRules)) {\n return $customRules;\n }\n }\n\n return [];\n }\n\n private function customProfileValidation(array $crmUser, array $customRules): bool\n {\n foreach ($customRules as $customRule) {\n if ($crmUser[$customRule['field']] !== $customRule['value']) {\n return false;\n }\n }\n\n return true;\n }\n\n /**\n * When syncing Contact / Lead / Account / Opportunity / Stage crm entities,\n * validate and restore locally trashed objects,\n * before updating them. Objects are identified by CrmProviderId\n */\n private function restoreAnyTrashedEntity(HasMany $targetEntity, string $crmProviderId): void\n {\n $recordExists = $targetEntity->withTrashed()->where(['crm_provider_id' => $crmProviderId])->first();\n if ($recordExists && $recordExists->trashed()) {\n $recordExists->restore();\n }\n }\n\n #[\\Override] public function supportsNotes(): bool\n {\n return true;\n }\n\n private function getOwnerProfile(?string $ownerId): ?Profile\n {\n if ($ownerId === null) {\n return null;\n }\n\n return $this->config->profiles()\n ->where('crm_provider_id', $ownerId)\n ->first();\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Help","depth":1,"on_screen":true,"help_text":"Show help contents","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Cancel","depth":1,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Commit","depth":1,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Commit","depth":2,"on_screen":true,"help_text":"Show drop-down menu (⌥⇧⏎)","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
-3947085877178480405
|
-7851939513083163707
|
typing_pause
|
accessibility
|
NULL
|
Diff
Changelist:
Changelist:
Changes
Show Diff
Rol Diff
Changelist:
Changelist:
Changes
Show Diff
Rollback...
Refresh
Group By
Expand All
Collapse All
1 modified
JY-2096
Commit Message
Commit Message History
Git
Author:
Author:
Amend commit
Sign-off commit
Create extra commit with file movements
Commit Checks
Update copyright
Reformat code
Rearrange code
Optimize imports
Cleanup
Choose profile
Check malicious dependencies
Run external formatter
Configure
Perform SonarQube for IDE analysis
Find unused view files
Run Git hooks
Advanced Commit Checks
Analyze code
Choose profile
Check TODO
Configure
After Commit
Upload files to:
Upload files to:
<None>
Always use selected server or group of servers
Previous Difference
Next Difference
Jump to Source
Compare Previous File
Compare Next File
Go to Changed File…
Side-by-side viewer
Do not ignore
Highlight words
Collapse Unchanged Fragments
Synchronize Scrolling
Disable Editing
Settings
Help
4 differences
1aee7aad
<?php
namespace Jiminny\Services\Crm\Salesforce;
use Carbon\Carbon;
use Exception;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Events\Dispatcher;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Jiminny\Component\Country\CountriesMap;
use Jiminny\Contracts\Acl\PermissionEnum;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Contracts\Services\Crm\FetchRelatedActivityInterface;
use Jiminny\Contracts\Services\Crm\ImportsBusinessProcessesInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\SalesforceBatchSyncInterface;
use Jiminny\Contracts\Services\Crm\Provider\SalesforceInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\RemoteNoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SearchTaskInterface;
use Jiminny\Contracts\Services\Crm\SendSummaryToCrmInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SupportsObjectTypeParseInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmProfileRecordTypesInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Enums\CrmObject;
use Jiminny\Events\Activities\Crm\LeadConverted;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpBadRequestException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Exceptions\NoResultsException;
use Jiminny\Exceptions\ServiceUnavailableException;
use Jiminny\Jobs\Crm\NoteObject;
use Jiminny\Jobs\Crm\MatchActivitiesToNewOpportunity;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Calendar\CalendarEvent;
use Jiminny\Models\Contact;
use Jiminny\Models\Contracts\ActivityContract;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Models\Crm\ContactRole;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Crm\RecordType;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Playbook;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\TeamSettings;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\ContactRoleRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\Crm\RecordTypeFieldValuesRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Helpers\ArrayIterator;
use Jiminny\Services\Crm\MatchDomainByEmailInterface;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Services\Crm\ResolveCompanyNameByEmailTrait;
use Jiminny\Services\Crm\Salesforce\Fields\FieldHelper;
use Jiminny\Services\Crm\Salesforce\Fields\FieldTypeConverter;
use Jiminny\Services\Crm\Salesforce\Fields\ValueNormalizer;
use Jiminny\Services\Crm\Salesforce\ServiceTraits\FollowupActivityTrait;
use Jiminny\Services\Crm\Salesforce\ServiceTraits\LogActivityTrait;
use Jiminny\Services\Crm\Salesforce\ServiceTraits\RecordManipulationsTrait;
use Jiminny\Services\Crm\Salesforce\ServiceTraits\SyncFieldsTrait;
use Jiminny\Utils\CurrencyFormatter;
use Jiminny\Utils\StringUtil;
use Ramsey\Uuid\Uuid;
use Sentry\Laravel\Facade as Sentry;
class Service extends BaseService implements
SalesforceInterface,
SalesforceBatchSyncInterface,
SyncCrmEntitiesInterface,
SyncCrmProfileRecordTypesInterface,
ImportsBusinessProcessesInterface,
RemoteEntityManipulationInterface,
FetchRelatedActivityInterface,
SendSummaryToCrmInterface,
MatchDomainByEmailInterface,
SearchTaskInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
SupportsObjectTypeParseInterface,
RemoteNoteEntityManipulationInterface,
VerifyTaskExistsInterface
{
use ResolveCompanyNameByEmailTrait;
use SyncFieldsTrait;
use DeleteObjectsTrait;
use RecordManipulationsTrait;
use ServiceTraits\BatchSyncTrait;
use FollowupActivityTrait;
use LogActivityTrait;
/**
* Note Body Limit for the Old Note-Taking Tool
*
* @var int
*/
private const int CLASSIC_NOTE_MAX_LENGTH = 32000;
/**
* Note Content Limit for the New Notes
*
* @var int
*/
private const int ENHANCED_NOTE_MAX_LENGTH = 50000000;
private const string INSTALLED_PACKAGE_ID = '033Tw0000007bKbIAI';
private const int CACHE_TTL = 600;
private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day - 86400
/**
* @var Client
*/
protected $client;
protected PayloadBuilder $payloadBuilder;
protected QueryHandler $queryHandler;
private OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
public function __construct(
Client $client,
PayloadBuilder $payloadBuilder,
protected Dispatcher $eventDispatcher,
private readonly CountriesMap $countriesMap,
private readonly ProspectPhotoPathService $prospectPhotoPathService,
) {
parent::__construct();
$this->client = $client;
$this->payloadBuilder = $payloadBuilder;
$this->queryHandler = app(QueryHandler::class, [
'client' => $this->client,
'logger' => $this->logger,
]);
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
}
public function getDisplayName(): string
{
return 'Salesforce';
}
public function getJobDelay(): int
{
return 1;
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
return $user->getSocialAccount(SocialAccount::PROVIDER_SALESFORCE);
}
public function verifyTaskExists(Activity $activity): bool
{
$crmProviderId = $activity->getCrmProviderId();
$cacheKey = "crm_task_exists:{$this->config->getId()}:$crmProviderId";
return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($activity, $crmProviderId) {
$playbook = $this->getPlaybookFromActivity($activity);
if ($playbook === null) {
$this->logger->warning('[Salesforce] Cannot verify task - no playbook found', [
'activity' => $activity->getId(),
'crm_provider_id' => $crmProviderId,
]);
return false;
}
$objectType = $playbook->getActivityType() === Playbook::ACTIVITY_TYPE_EVENT ? 'Event' : 'Task';
try {
$record = $this->getRecord($objectType, $crmProviderId, ['Id', 'IsDeleted']);
return ! empty($record) && ($record['IsDeleted'] ?? false) === false;
} catch (HttpNotFoundException|HttpBadRequestException) {
$this->logger->info('[Salesforce] Activity record not found during verification', [
'activity' => $activity->getId(),
'object_type' => $objectType,
'crm_provider_id' => $crmProviderId,
'config_id' => $this->config->getId(),
]);
return false;
}
});
}
public function query(string $queryToRun, array $parameters = []): QueryIterator
{
// Due to poorly designed external calls, this method cannot be entirely removed
return $this->queryHandler->query($queryToRun, $parameters);
}
/*=========== Organization Information ===============*/
/**
* Get a list of all the API Versions for the instance.
*
* @throws CrmException
*
* @return mixed
*
*/
public function getApiVersions()
{
$url = $this->config->crm_base_url . '/services/data';
$response = $this->client->get($url);
return json_decode($response->getBody(), true);
}
/**
* Gets the valid recordTypes for a given Salesforce Object via the describe API.
*/
private function getRecordTypes(string $crmObject): array
{
$url = $this->client->getObjectsUrl() . $crmObject . '/describe';
$response = $this->client->get($url);
$jsonResponse = json_decode($response->getBody(), true);
$fields = [];
foreach ($jsonResponse['recordTypeInfos'] as $row) {
$fields[] = ['recordTypeId' => $row['recordTypeId'], 'default' => $row['defaultRecordTypeMapping']];
}
return $fields;
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return ValueNormalizer::normalize($fieldType, $fieldValue, $internal);
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
$defaultFields = ($activityType === Playbook::ACTIVITY_TYPE_TASK)
? FieldDefinitions::defaultTaskFields()
: FieldDefinitions::defaultEventFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
// Setup the activity field as the default Type.
/** @var Field $activityField */
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'Type',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK, Playbook::ACTIVITY_TYPE_EVENT];
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldFilter = ($activityType === Playbook::ACTIVITY_TYPE_TASK)
? FieldDefinitions::taskFollowupFieldsFilter()
: FieldDefinitions::eventFollowupFieldsFilter();
foreach ($fieldFilter as $eachFilter) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $eachFilter);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
/**
* This one is now called only when ImportActivityTypes is triggered or SyncFieldMetadata executed manually
* Regular sync now uses SharedSyncFieldsTrait -> syncSingleObjectType
* Needs to be replaced later on
*/
public function syncField(Field $field): void
{
try {
if (FieldHelper::isCustomField($field)) {
$query = '
SELECT
Id, Metadata, TableEnumOrId
FROM
CustomField
WHERE
DeveloperName = :fieldName
AND
TableEnumOrId = :fieldType
AND
NamespacePrefix = :namespacePrefix';
// We need to constrain the field lookup to the object, in case it's used in multiple places.
$objectType = \in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true)
? 'activity'
: $field->object_type;
$sfFields = $this->queryHandler->metadata($query, [
'fieldName' => substr($field->crm_provider_id, 0, -\strlen('__c')),
'fieldType' => ucfirst($objectType),
// This is used to ensure we only consider the field within the org, not installed packages.
'namespacePrefix' => 'null',
]);
// There is always 1 result at this point.
$sfField = $sfFields->current();
// Sync field metadata.
$metadata = $sfField['Metadata'];
$field->description = mb_strimwidth($metadata['description'] ?? '', 0, 191);
$field->label = mb_strimwidth($metadata['label'] ?? '', 0, Field::LABEL_MAX_LENGTH);
$field->type = $this->convertFieldType($metadata['type'], $field->getEntityName());
$field->is_mandatory = ($metadata['required'] === true);
$field->length = $metadata['length'];
$field->default_value = mb_strimwidth(trim($metadata['defaultValue'] ?? '', '"'), 0, 191);
$field->save();
} else {
$query = '
SELECT
Id, DataType, DeveloperName, Label, Length, Description
FROM
FieldDefinition
WHERE
DurableId = :entityName';
$entityName = $field->getEntityName();
$sfFields = $this->queryHandler->metadata($query, [
'entityName' => $entityName,
]);
// There is always 1 result at this point.
$sfField = $sfFields->current();
$convertedType = $this->convertFieldType($sfField['DataType'], $entityName);
$label = mb_strimwidth($sfField['Label'], 0, Field::LABEL_MAX_LENGTH);
if ($field->isBusinessType()) {
$label = 'Opportunity Type';
}
$field->description = mb_strimwidth($sfField['Description'], 0, Field::DESCRIPTION_MAX_LENGTH);
$field->label = $label;
$field->type = $convertedType;
$field->length = $sfField['Length'];
$field->save();
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
}
private function convertFieldType(string $from, ?string $entityName = null): string
{
$converter = new FieldTypeConverter();
return $converter->convert($from, $entityName);
}
/**
* @inheritdoc
*/
public function importPicklistValues(Field $field): array
{
$values = [];
$fieldValues = [];
try {
if (FieldHelper::isCustomField($field)) {
$query = '
SELECT
Id, Metadata, TableEnumOrId
FROM
CustomField
WHERE
DeveloperName = :fieldName
AND
TableEnumOrId = :fieldType
AND
NamespacePrefix = :namespacePrefix';
// We need to constrain the field lookup to the object, in case it's used in multiple places.
$objectType = \in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true) ?
'activity' : $field->object_type;
$sfFields = $this->queryHandler->metadata($query, [
'fieldName' => substr($field->crm_provider_id, 0, -\strlen('__c')),
'fieldType' => ucfirst($objectType),
// This is used to ensure we only consider the field within the org, not installed packages.
'namespacePrefix' => 'null',
]);
// There is always 1 result at this point.
$sfField = $sfFields->current();
$valueSet = $sfField['Metadata']['valueSet'];
if ($valueSet['valueSetName'] === null) {
// Local picklist values can be obtained easily.
$picklistValues = $valueSet['valueSetDefinition']['value'];
} else {
// But for some fields, we just get the Global Value Picklist pointer so need to do more work.
$picklistValues = $this->importGlobalValuePicklistValues($valueSet['valueSetName']);
}
// Import all active values.
foreach ($picklistValues as $i => $sfFieldValue) {
// Setup default value.
if ($sfFieldValue['default']) {
$field->update(['default_value' => $sfFieldValue['valueName']]);
}
// This comes through as null if active (lol).
if ($sfFieldValue['isActive'] !== false) {
$values[] = [
'value' => $sfFieldValue['valueName'],
'label' => $sfFieldValue['valueName'],
'sequence' => $i,
'is_default' => $sfFieldValue['default'],
];
}
}
} else {
$objectFields = $this->getObjectFields($field->object_type);
$fieldId = $field->crm_provider_id;
// Only work with our field of interest.
$objectField = array_filter($objectFields, function ($item) use ($fieldId) {
return $item['name'] === $fieldId;
});
$objectField = array_shift($objectField);
if (empty($objectField['picklistValues']) === false) {
foreach ($objectField['picklistValues'] as $i => $sfFieldValue) {
// Skip inactive values.
if ($sfFieldValue['active'] === false) {
continue;
}
// Setup default value.
if ($sfFieldValue['defaultValue']) {
$field->update(['default_value' => $sfFieldValue['value']]);
}
$values[] = [
'value' => $sfFieldValue['value'],
'label' => $sfFieldValue['label'],
'sequence' => $i,
'is_default' => $sfFieldValue['defaultValue'],
];
}
}
}
$fieldsToPurge = $field->values()->get()->pluck('value')->toArray();
foreach ($values as $value) {
$value['value'] = substr($value['value'] ?? '', 0, 255);
$fieldValues[] = $field->values()->updateOrCreate([
'value' => $value['value'],
], $value);
// Remove this value from the ones we are going to purge.
if (($key = array_search($value['value'], $fieldsToPurge, true)) !== false) {
unset($fieldsToPurge[$key]);
}
}
// Delete the old values that are no longer used.
// Get IDs of the values to be deleted
$valuesToDelete = $field->values()->whereIn('value', $fieldsToPurge);
$valuesToDeleteIds = $valuesToDelete->pluck('id');
if (! $valuesToDeleteIds->isEmpty()) {
$recordTypeFieldValuesRepository = app(RecordTypeFieldValuesRepository::class);
$recordTypeFieldValuesRepository->deleteForCrmFieldValueIds($valuesToDeleteIds->toArray());
// Now safely delete from crm_field_values
$valuesToDelete->delete();
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
return $fieldValues;
}
/**
* Gets values from Global Value Picklists.
*/
private function importGlobalValuePicklistValues(string $picklistName): array
{
$query = '
SELECT
Metadata
FROM
GlobalValueSet
WHERE
DeveloperName = :picklistName
LIMIT 1';
try {
$sfValues = $this->queryHandler->metadata($query, [
'picklistName' => $picklistName,
]);
// There is always 1 result at this point.
$sfValue = $sfValues->current();
return $sfValue['Metadata']['customValue'];
} catch (NoResultsException $noResultsException) {
// Nothing returned.
return [];
}
}
/**
* @inheritdoc
*/
public function syncProfileRecordTypes(): void
{
$objectTypes = [
'lead',
'account',
'contact',
'opportunity',
'task',
'event',
];
foreach ($objectTypes as $objectType) {
try {
$crmRecordTypes = $this->getRecordTypes(ucfirst($objectType));
foreach ($crmRecordTypes as $crmRecordType) {
// If the record type is default and not the Master type, set this.
if ($crmRecordType['default'] && $crmRecordType['recordTypeId'] !== '012000000000000AAA') {
$recordType = $this->config->recordTypes()
->where('crm_provider_id', $crmRecordType['recordTypeId'])
->first();
if ($recordType) {
$this->profile->{$objectType . '_record_type_id'} = $recordType->id;
}
}
}
} catch (HttpNotFoundException $exception) {
Log::error('No access to ' . $objectType . ' object, skipping...');
// XXX: should we log this fact somewhere?
continue;
}
}
if ($this->profile->isDirty()) {
$this->profile->save();
}
}
/**
* Gets business processes.
*/
public function importBusinessProcesses(): void
{
$query = '
SELECT
Id, IsActive, Name, TableEnumOrId
FROM
BusinessProcess
WHERE
TableEnumOrId IN (\'Lead\',\'Opportunity\')';
try {
$sfProcesses = $this->queryHandler->query($query);
// Upsert all processes for the team.
foreach ($sfProcesses as $sfProcess) {
/** @var BusinessProcess $businessProcess */
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => $sfProcess['Id'],
], [
'team_id' => $this->team->id,
'name' => $sfProcess['Name'],
'type' => $sfProcess['TableEnumOrId'] === 'Lead' ? 'lead' : 'opportunity',
'is_selectable' => $sfProcess['IsActive'],
]);
$this->importBusinessProcessStages($businessProcess);
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
}
/**
* Gets business process stages.
*/
private function importBusinessProcessStages(BusinessProcess $businessProcess): void
{
$query = '
SELECT
Metadata
FROM
BusinessProcess
WHERE
Id = :processId';
try {
$stages = [];
$sfProcessStages = $this->queryHandler->metadata($query, [
'processId' => $businessProcess->crm_provider_id,
]);
// There is always 1 result at this point.
$sfProcessStage = $sfProcessStages->current();
// Upsert all processes for the team.
foreach ($sfProcessStage['Metadata']['values'] as $sfProcessStage) {
$sanitizedName = urldecode($sfProcessStage['valueName']); // Must decode: "%2C" becomes "," etc.
$stage = $businessProcess->crm->stages()
// This MUST match on label because this API doesn't use API Name.
->where('label', $sanitizedName)
->where('type', $businessProcess->type)
->where('is_selectable', 1)
->first();
if ($stage) {
$stages[] = $stage->id;
}
}
$businessProcess->stages()->sync($stages);
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
}
/**
* Gets record types.
*/
public function importRecordTypes(): void
{
$query = '
SELECT
Id, IsActive, Name, BusinessProcessId, SobjectType
FROM
RecordType';
try {
$sfRecordTypes = $this->queryHandler->query($query);
// Upsert all record types for the process.
foreach ($sfRecordTypes as $sfRecordType) {
$businessProcess = null;
if ($sfRecordType['BusinessProcessId']) {
$businessProcess = $this->config->businessProcesses()
->where('crm_provider_id', $sfRecordType['BusinessProcessId'])
->first();
}
/** @var RecordType $recordType */
$recordType = $this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => $sfRecordType['Id'],
], [
'team_id' => $this->team->id,
'type' => mb_strtolower($sfRecordType['SobjectType']),
'name' => $sfRecordType['Name'],
'is_selectable' => $sfRecordType['IsActive'],
'business_process_id' => $businessProcess->id ?? null,
]);
$this->importRecordTypeFieldValues($recordType);
}
} catch (NoResultsException $noResultsException) {
// Do nothing.
}
}
/**
* Import record type - field value mappings. This only works for standard fields.
*/
private function importRecordTypeFieldValues(RecordType $recordType): void
{
try {
$query = '
SELECT
Metadata
FROM
RecordType
WHERE
Id = :recordTypeId';
$sfFields = $this->queryHandler->metadata($query, [
'recordTypeId' => $recordType->crm_provider_id,
]);
// There is always 1 result at this point.
$sfField = $sfFields->current();
// Sync field metadata.
$picklists = $sfField['Metadata']['picklistValues'];
foreach ($picklists as $picklist) {
$field = $this->config->fields()->where([
'type' => Field::TYPE_PICKLIST,
'object_type' => $recordType->type,
'crm_provider_id' => $picklist['picklist'],
])->first();
if ($field) {
$fieldValues = [];
foreach ($picklist['values'] as $value) {
// Must decode: "%2C" becomes "," etc.
$fieldValue = $field->values()
->where('value', urldecode($value['valueName']))
->first();
if ($fieldValue) {
$fieldValues[] = $fieldValue->id;
}
}
$recordType->fieldValues()->sync($fieldValues);
}
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
}
/**
* @inheritdoc
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$params = [];
$missingStage = null;
if ($types === null) {
$types = [Stage::TYPE_LEAD, Stage::TYPE_OPPORTUNITY];
}
foreach ($types as $type) {
if ($type === Stage::TYPE_LEAD) {
$query = '
SELECT
Id, ApiName, MasterLabel, SortOrder
FROM
LeadStatus';
} else {
$query = '
SELECT
Id, ApiName, MasterLabel, IsActive, SortOrder, DefaultProbability
FROM
OpportunityStage';
}
if ($missingStageName) {
$escapedStageName = ValueNormalizer::replaceQueryWithStringLiterals($missingStageName);
$query .= ' WHERE ApiName = :stageName';
$params = [
'stageName' => $escapedStageName,
];
}
try {
$sfStages = $this->queryHandler->query($query, $params);
} catch (NoResultsException $exception) {
$sfStages = [];
}
$missingStage = null;
// Upsert all stages for the team.
foreach ($sfStages as $sfStage) {
$selectable = true;
if (array_key_exists('IsActive', $sfStage)) {
$selectable = $sfStage['IsActive'];
}
$this->restoreAnyTrashedEntity($this->config->stages(), $sfStage['Id']);
$stage = $this->config->stages()->updateOrCreate([
'crm_provider_id' => $sfStage['Id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($sfStage['ApiName'], 0, 50),
'label' => mb_strimwidth($sfStage['MasterLabel'], 0, 191),
'type' => $type,
'sequence' => $sfStage['SortOrder'] ?? 0,
'is_selectable' => $selectable,
'probability' => $sfStage['DefaultProbability'] ?? null,
]);
if ($missingStageName && $missingStageName === $sfStage['ApiName']) {
$missingStage = $stage;
}
}
if ($missingStageName && $missingStage === null) {
// If they requested a stage that still doesn't exist, it must be inactive so lazy create it.
$missingStage = $this->config->stages()->create([
'crm_provider_id' => Uuid::uuid4(),
'team_id' => $this->team->id,
'name' => mb_strimwidth($missingStageName, 0, 50),
'label' => mb_strimwidth($missingStageName, 0, 191),
'type' => $type,
'sequence' => 0,
'is_selectable' => 0,
]);
}
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int
{
$syncCount = 0;
$fields = $this->getAllFieldsAsArray('lead');
if (\in_array('Id', $fields, true) === false) {
return $syncCount;
}
$query = '
SELECT ' . rtrim(implode(',', $fields), ',') . '
FROM Lead
WHERE LastModifiedDate > :since
ORDER BY LastModifiedDate ASC';
try {
$sfLeads = $this->queryHandler->query($query, [
'since' => $since->format('Y-m-d\TH:i:s\Z'),
]);
foreach ($sfLeads as $sfLead) {
// Only sync if previously imported.
if ($this->hasLead($sfLead['Id'])) {
$this->importLead($sfLead);
$syncCount++;
}
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
$this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::LEAD);
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncLead(string $crmId): ?Lead
{
$fields = $this->getAllFieldsAsArray('lead');
$sfLead = $this->getRecord('Lead', $crmId, $fields);
return $this->importLead($sfLead);
}
private function importLead($crmData): ?Lead
{
/** @var ?Stage $stage */
$stage = null;
if (isset($crmData['Status'])) {
// Get the current stage.
$stage = $this->config
->stages()
->where('name', $crmData['Status'])
->where('type', Stage::TYPE_LEAD)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_LEAD], $crmData['Status']);
}
}
// If we have no way of importing this, just return null :(
if ($stage === null) {
return null;
}
$countryCode = $crmData['CountryCode'] ?? null;
// Salesforce allows custom "countries" to be created. Disregard these.
if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {
$countryCode = null;
}
// If we have no country code, try to parse it from the country name.
if ($countryCode === null && empty($crmData['Country']) !== false) {
$countryCode = $this->convertCountryNameToCode($crmData['Country']);
}
// Trim to our width and attempt to parse it.
$number = mb_strimwidth($crmData['Phone'] ?? '', 0, 25);
$parsedNumber = parsePhoneNumber($countryCode, $number);
$mobilePhone = null;
if (empty($crmData['MobilePhone']) === false) {
// Trim to our width and attempt to parse it.
$number = mb_strimwidth($crmData['MobilePhone'], 0, 25);
$mobilePhone = phone_e164($countryCode, $number);
}
$convertedDate = null;
$convertedAccount = null;
$convertedOpportunity = null;
$convertedContact = null;
if ($crmData['IsConverted'] == 'true') {
$convertedDate = $crmData['ConvertedDate'];
if (empty($crmData['ConvertedAccountId']) === false) {
$convertedAccount = $this->config
->accounts()
->where('crm_provider_id', $crmData['ConvertedAccountId'])
->first();
if ($convertedAccount === null) {
try {
$convertedAccount = $this->syncAccount($crmData['ConvertedAccountId']);
} catch (HttpNotFoundException $exception) {
// Probably the user has no permissions to access the converted data.
}
}
}
if (empty($crmData['ConvertedOpportunityId']) === false) {
$convertedOpportunity = $this->config
->opportunities()
->where('crm_provider_id', $crmData['ConvertedOpportunityId'])
->first();
if ($convertedOpportunity === null) {
try {
$convertedOpportunity = $this->syncOpportunity($crmData['ConvertedOpportunityId']);
} catch (HttpNotFoundException $exception) {
// Probably the user has no permissions to access the converted data.
}
}
}
if (empty($crmData['ConvertedContactId']) === false) {
$convertedContact = $this->team
->crm
->contacts()
->where('crm_provider_id', $crmData['ConvertedContactId'])
->first();
if ($convertedContact === null) {
try {
$convertedContact = $this->syncContact($crmData['ConvertedContactId']);
} catch (HttpNotFoundException $exception) {
// Probably the user has no permissions to access the converted data.
}
}
}
}
if (empty($crmData['Company'])) {
$company = 'Unknown';
} else {
$company = mb_strimwidth($crmData['Company'], 0, 191);
}
$domain = null;
if (empty($crmData['Website']) === false) {
$domain = mb_strimwidth($crmData['Website'], 0, 191);
$domain = StringUtil::resolveDomain($domain);
}
$createdDate = null;
if (empty($crmData['CreatedDate']) === false) {
$createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');
}
$profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);
$data = [
'team_id' => $this->team->id,
'user_id' => $profile?->user_id,
'owner_id' => $crmData['OwnerId'] ?? '',
'company' => $company,
'domain' => $domain,
'name' => $crmData['Name'] ? mb_strimwidth($crmData['Name'], 0, 191) : '',
'title' => $crmData['Title'] ? mb_strimwidth($crmData['Title'], 0, 128) : null,
'email' => $crmData['Email'] ? mb_strimwidth($crmData['Email'], 0, 80) : null,
'phone' => $parsedNumber['phone'],
'ext' => $parsedNumber['ext'] ?? null,
'mobile_phone' => $mobilePhone,
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: $crmData['Id'],
modelType: Lead::class,
fileName: $crmData['Id'],
avatarText: $crmData['Name']
),
'stage_id' => $stage->id,
'record_type_id' => null,
'converted_at' => $convertedDate,
'converted_account_id' => $convertedAccount->id ?? null,
'converted_opportunity_id' => $convertedOpportunity->id ?? null,
'converted_contact_id' => $convertedContact->id ?? null,
'country_code' => $countryCode,
'remotely_created_at' => $createdDate,
];
$this->restoreAnyTrashedEntity($this->config->leads(), $crmData['Id']);
/** @var Lead $lead */
$lead = $this->config->leads()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);
if ($lead->wasChanged('converted_at') && $lead->getConvertedAt() !== null) {
$this->eventDispatcher->dispatch(new LeadConverted($lead));
}
$this->handleObjectDeletion($lead, $crmData);
return $lead;
}
/**
* @inheritdoc
*/
public function syncAccounts(Carbon $since, ?Carbon $to = null): int
{
$syncCount = 0;
$fields = $this->getAllFieldsAsArray('account');
if (\in_array('Id', $fields, true) === false) {
return $syncCount;
}
$query = '
SELECT ' . rtrim(implode(',', $fields), ',') . '
FROM Account
WHERE LastModifiedDate > :since
ORDER BY LastModifiedDate ASC';
try {
$sfAccounts = $this->queryHandler->query($query, [
'since' => $since->format('Y-m-d\TH:i:s\Z'),
]);
foreach ($sfAccounts as $sfAccount) {
// Only sync if previously imported.
if ($this->hasAccount($sfAccount['Id'])) {
$this->importAccount($sfAccount);
$syncCount++;
}
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
$this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::ACCOUNT);
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncAccount(string $crmId): ?Account
{
$fields = $this->getAllFieldsAsArray('account');
if (! in_array('Id', $fields, true)) {
$this->logger->info('[Salesforce] Sync account cancelled. Fields are not available.', [
'crmId' => $crmId,
'userId' => $this->profile->getUserId(),
]);
return null;
}
$sfAccount = $this->getRecord('Account', $crmId, $fields);
return $this->importAccount($sfAccount);
}
private function importAccount($crmData): Account
{
$countryCode = $crmData['BillingCountryCode'] ?? $crmData['ShippingCountryCode'] ?? null;
// Salesforce allows custom "countries" to be created. Disregard these.
if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {
$countryCode = null;
}
// If we have no country code, try to parse it from the country names.
if ($countryCode === null && empty($crmData['BillingCountry']) === false) {
$countryCode = $this->convertCountryNameToCode($crmData['BillingCountry']);
}
if ($countryCode === null && empty($crmData['ShippingCountry']) === false) {
$countryCode = $this->convertCountryNameToCode($crmData['ShippingCountry']);
}
if (empty($crmData['Phone']) === false) {
// Trim to our width and attempt to parse it.
$number = mb_strimwidth($crmData['Phone'], 0, 25);
$parsedNumber = parsePhoneNumber($countryCode, $number);
} else {
$parsedNumber = [];
}
$industry = null;
if (empty($crmData['Industry']) === false) {
$industry = mb_strimwidth($crmData['Industry'], 0, 40);
}
$domain = null;
if (empty($crmData['Website']) === false) {
$domain = mb_strimwidth($crmData['Website'], 0, 191);
$domain = StringUtil::resolveDomain($domain);
}
$createdDate = null;
if (empty($crmData['CreatedDate']) === false) {
$createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');
}
$profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);
$data = [
'team_id' => $this->team->id,
'user_id' => $profile?->user_id,
'owner_id' => $crmData['OwnerId'],
'name' => mb_strimwidth($crmData['Name'], 0, 191),
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: $crmData['Id'],
modelType: Account::class,
fileName: $crmData['Id'],
avatarText: $crmData['Name']
),
'industry' => $industry,
'domain' => $domain,
'phone' => $parsedNumber['phone'] ?? null,
'ext' => $parsedNumber['ext'] ?? null,
'country_code' => $countryCode,
'remotely_created_at' => $createdDate,
];
$this->restoreAnyTrashedEntity($this->config->accounts(), $crmData['Id']);
/** @var Account $account */
$account = $this->config->accounts()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);
$this->handleObjectDeletion($account, $crmData);
return $account;
}
/**
* @inheritdoc
*/
public function syncOpportunities(array $parameters, ?string $strategy = null): int
{
$strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);
$syncCount = 0;
$logParams = $parameters;
$parameters['profile'] = $this->profile;
$logParams['user'] = $this->profile->getUserId();
if (count($strategies) > 1) {
$this->logger->warning('[' . $this->getDisplayName() . '] Multiple sync strategies used', [
'teamId' => $this->team->getUuid(),
'params' => $logParams,
'strategies_count' => count($strategies),
]);
}
foreach ($strategies as $syncStrategy) {
$name = $syncStrategy->getStrategyName();
try {
$sfOpportunities = $syncStrategy->fetchOpportunities($parameters);
$totalRecords = $sfOpportunities->count();
foreach ($sfOpportunities as $sfOpportunity) {
$this->importOpportunity($sfOpportunity);
$syncCount++;
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
$this->logger->warning('[' . $this->getDisplayName() . '] No opportunities found', [
'teamId' => $this->team->getUuid(),
'name' => $name,
'params' => $logParams,
'reason' => $noResultsException->getMessage(),
]);
} catch (CrmException $crmException) {
// Nothing to sync.
$this->logger->warning('[' . $this->getDisplayName() . '] Opportunity sync failed', [
'teamId' => $this->team->getUuid(),
'name' => $name,
'params' => $logParams,
'reason' => $crmException->getMessage(),
]);
}
}
$this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::OPPORTUNITY, ['params' => $logParams]);
// debug to see how if count of opportunities reaches 1000
if ($syncCount >= 1000) {
$this->logger->info(
'[' . $this->getDisplayName() . '] Sync Opportunities - count warning',
[
'team_id' => $this->team->getId(),
'params' => $logParams,
'count' => $syncCount,
'strategies_count' => count($strategies),
'total_records' => $totalRecords ?? null,
]
);
}
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncOpportunity(string $crmId): ?Opportunity
{
$strategy = $this->opportunitySyncStrategyResolver->resolve(
$this->config,
OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY
);
$parameters = [
'profile' => $this->profile,
'crm_id' => $crmId,
];
try {
$sfOpportunity = $strategy->fetchOpportunities($parameters);
} catch (HttpNotFoundException $e) {
$this->logger->info('[' . $this->getDisplayName() . '] Opportunity not found', [
'teamId' => $this->team->id_string,
'crmId' => $crmId,
]);
return null;
} catch (CrmException $crmException) {
$this->logger->info('[' . $this->getDisplayName() . '] Opportunity sync failed', [
'teamId' => $this->team->id_string,
'crmId' => $crmId,
'exception' => $crmException->getMessage(),
]);
return null;
}
if ($sfOpportunity instanceof ArrayIterator) {
return $this->importOpportunity($sfOpportunity->getItems());
}
return $this->importOpportunity($sfOpportunity);
}
/**
* @throws HttpNotFoundException
*/
private function importOpportunity($crmData): ?Opportunity
{
$profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);
$account = null;
if (empty($crmData['AccountId']) === false) {
/** @var ?Account $account */
$account = $this->config->accounts()
->where('crm_provider_id', (string) $crmData['AccountId'])
->first();
if ($account === null) {
$account = $this->syncAccount($crmData['AccountId']);
}
}
$userId = $profile?->getUserId() ?? $account?->getUserId();
if ($...
|
70644
|
NULL
|
NULL
|
NULL
|
|
70647
|
2537
|
18
|
2026-05-22T10:26:45.508927+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779445605508_m2.jpg...
|
PhpStorm
|
Commit Changes
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Diff
Changelist:
Changelist:
Changes
Show Diff
Rol Diff
Changelist:
Changelist:
Changes
Show Diff
Rollback...
Refresh
Group By
Expand All
Collapse All
1 modified
JY-209
Commit Message
Commit Message History
Git
Author:
Author:
Amend commit
Sign-off commit
Create extra commit with file movements
Commit Checks
Update copyright
Reformat code
Rearrange code
Optimize imports
Cleanup
Choose profile
Check malicious dependencies
Run external formatter
Configure
Perform SonarQube for IDE analysis
Find unused view files
Run Git hooks
Advanced Commit Checks
Analyze code
Choose profile
Check TODO
Configure
After Commit
Upload files to:
Upload files to:
<None>
Always use selected server or group of servers
Previous Difference
Next Difference
Jump to Source
Compare Previous File
Compare Next File
Go to Changed File…
Side-by-side viewer
Do not ignore
Highlight words
Collapse Unchanged Fragments
Synchronize Scrolling
Disable Editing
Settings
Help
4 differences
1aee7aad
<?php
namespace Jiminny\Services\Crm\Salesforce;
use Carbon\Carbon;
use Exception;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Events\Dispatcher;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Jiminny\Component\Country\CountriesMap;
use Jiminny\Contracts\Acl\PermissionEnum;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Contracts\Services\Crm\FetchRelatedActivityInterface;
use Jiminny\Contracts\Services\Crm\ImportsBusinessProcessesInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\SalesforceBatchSyncInterface;
use Jiminny\Contracts\Services\Crm\Provider\SalesforceInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\RemoteNoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SearchTaskInterface;
use Jiminny\Contracts\Services\Crm\SendSummaryToCrmInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SupportsObjectTypeParseInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmProfileRecordTypesInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Enums\CrmObject;
use Jiminny\Events\Activities\Crm\LeadConverted;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpBadRequestException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Exceptions\NoResultsException;
use Jiminny\Exceptions\ServiceUnavailableException;
use Jiminny\Jobs\Crm\NoteObject;
use Jiminny\Jobs\Crm\MatchActivitiesToNewOpportunity;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Calendar\CalendarEvent;
use Jiminny\Models\Contact;
use Jiminny\Models\Contracts\ActivityContract;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Models\Crm\ContactRole;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Crm\RecordType;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Playbook;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\TeamSettings;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\ContactRoleRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\Crm\RecordTypeFieldValuesRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Helpers\ArrayIterator;
use Jiminny\Services\Crm\MatchDomainByEmailInterface;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Services\Crm\ResolveCompanyNameByEmailTrait;
use Jiminny\Services\Crm\Salesforce\Fields\FieldHelper;
use Jiminny\Services\Crm\Salesforce\Fields\FieldTypeConverter;
use Jiminny\Services\Crm\Salesforce\Fields\ValueNormalizer;
use Jiminny\Services\Crm\Salesforce\ServiceTraits\FollowupActivityTrait;
use Jiminny\Services\Crm\Salesforce\ServiceTraits\LogActivityTrait;
use Jiminny\Services\Crm\Salesforce\ServiceTraits\RecordManipulationsTrait;
use Jiminny\Services\Crm\Salesforce\ServiceTraits\SyncFieldsTrait;
use Jiminny\Utils\CurrencyFormatter;
use Jiminny\Utils\StringUtil;
use Ramsey\Uuid\Uuid;
use Sentry\Laravel\Facade as Sentry;
class Service extends BaseService implements
SalesforceInterface,
SalesforceBatchSyncInterface,
SyncCrmEntitiesInterface,
SyncCrmProfileRecordTypesInterface,
ImportsBusinessProcessesInterface,
RemoteEntityManipulationInterface,
FetchRelatedActivityInterface,
SendSummaryToCrmInterface,
MatchDomainByEmailInterface,
SearchTaskInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
SupportsObjectTypeParseInterface,
RemoteNoteEntityManipulationInterface,
VerifyTaskExistsInterface
{
use ResolveCompanyNameByEmailTrait;
use SyncFieldsTrait;
use DeleteObjectsTrait;
use RecordManipulationsTrait;
use ServiceTraits\BatchSyncTrait;
use FollowupActivityTrait;
use LogActivityTrait;
/**
* Note Body Limit for the Old Note-Taking Tool
*
* @var int
*/
private const int CLASSIC_NOTE_MAX_LENGTH = 32000;
/**
* Note Content Limit for the New Notes
*
* @var int
*/
private const int ENHANCED_NOTE_MAX_LENGTH = 50000000;
private const string INSTALLED_PACKAGE_ID = '033Tw0000007bKbIAI';
private const int CACHE_TTL = 600;
private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day - 86400
/**
* @var Client
*/
protected $client;
protected PayloadBuilder $payloadBuilder;
protected QueryHandler $queryHandler;
private OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
public function __construct(
Client $client,
PayloadBuilder $payloadBuilder,
protected Dispatcher $eventDispatcher,
private readonly CountriesMap $countriesMap,
private readonly ProspectPhotoPathService $prospectPhotoPathService,
) {
parent::__construct();
$this->client = $client;
$this->payloadBuilder = $payloadBuilder;
$this->queryHandler = app(QueryHandler::class, [
'client' => $this->client,
'logger' => $this->logger,
]);
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
}
public function getDisplayName(): string
{
return 'Salesforce';
}
public function getJobDelay(): int
{
return 1;
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
return $user->getSocialAccount(SocialAccount::PROVIDER_SALESFORCE);
}
public function verifyTaskExists(Activity $activity): bool
{
$crmProviderId = $activity->getCrmProviderId();
$cacheKey = "crm_task_exists:{$this->config->getId()}:$crmProviderId";
return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($activity, $crmProviderId) {
$playbook = $this->getPlaybookFromActivity($activity);
if ($playbook === null) {
$this->logger->warning('[Salesforce] Cannot verify task - no playbook found', [
'activity' => $activity->getId(),
'crm_provider_id' => $crmProviderId,
]);
return false;
}
$objectType = $playbook->getActivityType() === Playbook::ACTIVITY_TYPE_EVENT ? 'Event' : 'Task';
try {
$record = $this->getRecord($objectType, $crmProviderId, ['Id', 'IsDeleted']);
return ! empty($record) && ($record['IsDeleted'] ?? false) === false;
} catch (HttpNotFoundException|HttpBadRequestException) {
$this->logger->info('[Salesforce] Activity record not found during verification', [
'activity' => $activity->getId(),
'object_type' => $objectType,
'crm_provider_id' => $crmProviderId,
'config_id' => $this->config->getId(),
]);
return false;
}
});
}
public function query(string $queryToRun, array $parameters = []): QueryIterator
{
// Due to poorly designed external calls, this method cannot be entirely removed
return $this->queryHandler->query($queryToRun, $parameters);
}
/*=========== Organization Information ===============*/
/**
* Get a list of all the API Versions for the instance.
*
* @throws CrmException
*
* @return mixed
*
*/
public function getApiVersions()
{
$url = $this->config->crm_base_url . '/services/data';
$response = $this->client->get($url);
return json_decode($response->getBody(), true);
}
/**
* Gets the valid recordTypes for a given Salesforce Object via the describe API.
*/
private function getRecordTypes(string $crmObject): array
{
$url = $this->client->getObjectsUrl() . $crmObject . '/describe';
$response = $this->client->get($url);
$jsonResponse = json_decode($response->getBody(), true);
$fields = [];
foreach ($jsonResponse['recordTypeInfos'] as $row) {
$fields[] = ['recordTypeId' => $row['recordTypeId'], 'default' => $row['defaultRecordTypeMapping']];
}
return $fields;
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return ValueNormalizer::normalize($fieldType, $fieldValue, $internal);
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
$defaultFields = ($activityType === Playbook::ACTIVITY_TYPE_TASK)
? FieldDefinitions::defaultTaskFields()
: FieldDefinitions::defaultEventFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
// Setup the activity field as the default Type.
/** @var Field $activityField */
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'Type',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK, Playbook::ACTIVITY_TYPE_EVENT];
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldFilter = ($activityType === Playbook::ACTIVITY_TYPE_TASK)
? FieldDefinitions::taskFollowupFieldsFilter()
: FieldDefinitions::eventFollowupFieldsFilter();
foreach ($fieldFilter as $eachFilter) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $eachFilter);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
/**
* This one is now called only when ImportActivityTypes is triggered or SyncFieldMetadata executed manually
* Regular sync now uses SharedSyncFieldsTrait -> syncSingleObjectType
* Needs to be replaced later on
*/
public function syncField(Field $field): void
{
try {
if (FieldHelper::isCustomField($field)) {
$query = '
SELECT
Id, Metadata, TableEnumOrId
FROM
CustomField
WHERE
DeveloperName = :fieldName
AND
TableEnumOrId = :fieldType
AND
NamespacePrefix = :namespacePrefix';
// We need to constrain the field lookup to the object, in case it's used in multiple places.
$objectType = \in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true)
? 'activity'
: $field->object_type;
$sfFields = $this->queryHandler->metadata($query, [
'fieldName' => substr($field->crm_provider_id, 0, -\strlen('__c')),
'fieldType' => ucfirst($objectType),
// This is used to ensure we only consider the field within the org, not installed packages.
'namespacePrefix' => 'null',
]);
// There is always 1 result at this point.
$sfField = $sfFields->current();
// Sync field metadata.
$metadata = $sfField['Metadata'];
$field->description = mb_strimwidth($metadata['description'] ?? '', 0, 191);
$field->label = mb_strimwidth($metadata['label'] ?? '', 0, Field::LABEL_MAX_LENGTH);
$field->type = $this->convertFieldType($metadata['type'], $field->getEntityName());
$field->is_mandatory = ($metadata['required'] === true);
$field->length = $metadata['length'];
$field->default_value = mb_strimwidth(trim($metadata['defaultValue'] ?? '', '"'), 0, 191);
$field->save();
} else {
$query = '
SELECT
Id, DataType, DeveloperName, Label, Length, Description
FROM
FieldDefinition
WHERE
DurableId = :entityName';
$entityName = $field->getEntityName();
$sfFields = $this->queryHandler->metadata($query, [
'entityName' => $entityName,
]);
// There is always 1 result at this point.
$sfField = $sfFields->current();
$convertedType = $this->convertFieldType($sfField['DataType'], $entityName);
$label = mb_strimwidth($sfField['Label'], 0, Field::LABEL_MAX_LENGTH);
if ($field->isBusinessType()) {
$label = 'Opportunity Type';
}
$field->description = mb_strimwidth($sfField['Description'], 0, Field::DESCRIPTION_MAX_LENGTH);
$field->label = $label;
$field->type = $convertedType;
$field->length = $sfField['Length'];
$field->save();
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
}
private function convertFieldType(string $from, ?string $entityName = null): string
{
$converter = new FieldTypeConverter();
return $converter->convert($from, $entityName);
}
/**
* @inheritdoc
*/
public function importPicklistValues(Field $field): array
{
$values = [];
$fieldValues = [];
try {
if (FieldHelper::isCustomField($field)) {
$query = '
SELECT
Id, Metadata, TableEnumOrId
FROM
CustomField
WHERE
DeveloperName = :fieldName
AND
TableEnumOrId = :fieldType
AND
NamespacePrefix = :namespacePrefix';
// We need to constrain the field lookup to the object, in case it's used in multiple places.
$objectType = \in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true) ?
'activity' : $field->object_type;
$sfFields = $this->queryHandler->metadata($query, [
'fieldName' => substr($field->crm_provider_id, 0, -\strlen('__c')),
'fieldType' => ucfirst($objectType),
// This is used to ensure we only consider the field within the org, not installed packages.
'namespacePrefix' => 'null',
]);
// There is always 1 result at this point.
$sfField = $sfFields->current();
$valueSet = $sfField['Metadata']['valueSet'];
if ($valueSet['valueSetName'] === null) {
// Local picklist values can be obtained easily.
$picklistValues = $valueSet['valueSetDefinition']['value'];
} else {
// But for some fields, we just get the Global Value Picklist pointer so need to do more work.
$picklistValues = $this->importGlobalValuePicklistValues($valueSet['valueSetName']);
}
// Import all active values.
foreach ($picklistValues as $i => $sfFieldValue) {
// Setup default value.
if ($sfFieldValue['default']) {
$field->update(['default_value' => $sfFieldValue['valueName']]);
}
// This comes through as null if active (lol).
if ($sfFieldValue['isActive'] !== false) {
$values[] = [
'value' => $sfFieldValue['valueName'],
'label' => $sfFieldValue['valueName'],
'sequence' => $i,
'is_default' => $sfFieldValue['default'],
];
}
}
} else {
$objectFields = $this->getObjectFields($field->object_type);
$fieldId = $field->crm_provider_id;
// Only work with our field of interest.
$objectField = array_filter($objectFields, function ($item) use ($fieldId) {
return $item['name'] === $fieldId;
});
$objectField = array_shift($objectField);
if (empty($objectField['picklistValues']) === false) {
foreach ($objectField['picklistValues'] as $i => $sfFieldValue) {
// Skip inactive values.
if ($sfFieldValue['active'] === false) {
continue;
}
// Setup default value.
if ($sfFieldValue['defaultValue']) {
$field->update(['default_value' => $sfFieldValue['value']]);
}
$values[] = [
'value' => $sfFieldValue['value'],
'label' => $sfFieldValue['label'],
'sequence' => $i,
'is_default' => $sfFieldValue['defaultValue'],
];
}
}
}
$fieldsToPurge = $field->values()->get()->pluck('value')->toArray();
foreach ($values as $value) {
$value['value'] = substr($value['value'] ?? '', 0, 255);
$fieldValues[] = $field->values()->updateOrCreate([
'value' => $value['value'],
], $value);
// Remove this value from the ones we are going to purge.
if (($key = array_search($value['value'], $fieldsToPurge, true)) !== false) {
unset($fieldsToPurge[$key]);
}
}
// Delete the old values that are no longer used.
// Get IDs of the values to be deleted
$valuesToDelete = $field->values()->whereIn('value', $fieldsToPurge);
$valuesToDeleteIds = $valuesToDelete->pluck('id');
if (! $valuesToDeleteIds->isEmpty()) {
$recordTypeFieldValuesRepository = app(RecordTypeFieldValuesRepository::class);
$recordTypeFieldValuesRepository->deleteForCrmFieldValueIds($valuesToDeleteIds->toArray());
// Now safely delete from crm_field_values
$valuesToDelete->delete();
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
return $fieldValues;
}
/**
* Gets values from Global Value Picklists.
*/
private function importGlobalValuePicklistValues(string $picklistName): array
{
$query = '
SELECT
Metadata
FROM
GlobalValueSet
WHERE
DeveloperName = :picklistName
LIMIT 1';
try {
$sfValues = $this->queryHandler->metadata($query, [
'picklistName' => $picklistName,
]);
// There is always 1 result at this point.
$sfValue = $sfValues->current();
return $sfValue['Metadata']['customValue'];
} catch (NoResultsException $noResultsException) {
// Nothing returned.
return [];
}
}
/**
* @inheritdoc
*/
public function syncProfileRecordTypes(): void
{
$objectTypes = [
'lead',
'account',
'contact',
'opportunity',
'task',
'event',
];
foreach ($objectTypes as $objectType) {
try {
$crmRecordTypes = $this->getRecordTypes(ucfirst($objectType));
foreach ($crmRecordTypes as $crmRecordType) {
// If the record type is default and not the Master type, set this.
if ($crmRecordType['default'] && $crmRecordType['recordTypeId'] !== '012000000000000AAA') {
$recordType = $this->config->recordTypes()
->where('crm_provider_id', $crmRecordType['recordTypeId'])
->first();
if ($recordType) {
$this->profile->{$objectType . '_record_type_id'} = $recordType->id;
}
}
}
} catch (HttpNotFoundException $exception) {
Log::error('No access to ' . $objectType . ' object, skipping...');
// XXX: should we log this fact somewhere?
continue;
}
}
if ($this->profile->isDirty()) {
$this->profile->save();
}
}
/**
* Gets business processes.
*/
public function importBusinessProcesses(): void
{
$query = '
SELECT
Id, IsActive, Name, TableEnumOrId
FROM
BusinessProcess
WHERE
TableEnumOrId IN (\'Lead\',\'Opportunity\')';
try {
$sfProcesses = $this->queryHandler->query($query);
// Upsert all processes for the team.
foreach ($sfProcesses as $sfProcess) {
/** @var BusinessProcess $businessProcess */
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => $sfProcess['Id'],
], [
'team_id' => $this->team->id,
'name' => $sfProcess['Name'],
'type' => $sfProcess['TableEnumOrId'] === 'Lead' ? 'lead' : 'opportunity',
'is_selectable' => $sfProcess['IsActive'],
]);
$this->importBusinessProcessStages($businessProcess);
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
}
/**
* Gets business process stages.
*/
private function importBusinessProcessStages(BusinessProcess $businessProcess): void
{
$query = '
SELECT
Metadata
FROM
BusinessProcess
WHERE
Id = :processId';
try {
$stages = [];
$sfProcessStages = $this->queryHandler->metadata($query, [
'processId' => $businessProcess->crm_provider_id,
]);
// There is always 1 result at this point.
$sfProcessStage = $sfProcessStages->current();
// Upsert all processes for the team.
foreach ($sfProcessStage['Metadata']['values'] as $sfProcessStage) {
$sanitizedName = urldecode($sfProcessStage['valueName']); // Must decode: "%2C" becomes "," etc.
$stage = $businessProcess->crm->stages()
// This MUST match on label because this API doesn't use API Name.
->where('label', $sanitizedName)
->where('type', $businessProcess->type)
->where('is_selectable', 1)
->first();
if ($stage) {
$stages[] = $stage->id;
}
}
$businessProcess->stages()->sync($stages);
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
}
/**
* Gets record types.
*/
public function importRecordTypes(): void
{
$query = '
SELECT
Id, IsActive, Name, BusinessProcessId, SobjectType
FROM
RecordType';
try {
$sfRecordTypes = $this->queryHandler->query($query);
// Upsert all record types for the process.
foreach ($sfRecordTypes as $sfRecordType) {
$businessProcess = null;
if ($sfRecordType['BusinessProcessId']) {
$businessProcess = $this->config->businessProcesses()
->where('crm_provider_id', $sfRecordType['BusinessProcessId'])
->first();
}
/** @var RecordType $recordType */
$recordType = $this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => $sfRecordType['Id'],
], [
'team_id' => $this->team->id,
'type' => mb_strtolower($sfRecordType['SobjectType']),
'name' => $sfRecordType['Name'],
'is_selectable' => $sfRecordType['IsActive'],
'business_process_id' => $businessProcess->id ?? null,
]);
$this->importRecordTypeFieldValues($recordType);
}
} catch (NoResultsException $noResultsException) {
// Do nothing.
}
}
/**
* Import record type - field value mappings. This only works for standard fields.
*/
private function importRecordTypeFieldValues(RecordType $recordType): void
{
try {
$query = '
SELECT
Metadata
FROM
RecordType
WHERE
Id = :recordTypeId';
$sfFields = $this->queryHandler->metadata($query, [
'recordTypeId' => $recordType->crm_provider_id,
]);
// There is always 1 result at this point.
$sfField = $sfFields->current();
// Sync field metadata.
$picklists = $sfField['Metadata']['picklistValues'];
foreach ($picklists as $picklist) {
$field = $this->config->fields()->where([
'type' => Field::TYPE_PICKLIST,
'object_type' => $recordType->type,
'crm_provider_id' => $picklist['picklist'],
])->first();
if ($field) {
$fieldValues = [];
foreach ($picklist['values'] as $value) {
// Must decode: "%2C" becomes "," etc.
$fieldValue = $field->values()
->where('value', urldecode($value['valueName']))
->first();
if ($fieldValue) {
$fieldValues[] = $fieldValue->id;
}
}
$recordType->fieldValues()->sync($fieldValues);
}
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
}
/**
* @inheritdoc
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$params = [];
$missingStage = null;
if ($types === null) {
$types = [Stage::TYPE_LEAD, Stage::TYPE_OPPORTUNITY];
}
foreach ($types as $type) {
if ($type === Stage::TYPE_LEAD) {
$query = '
SELECT
Id, ApiName, MasterLabel, SortOrder
FROM
LeadStatus';
} else {
$query = '
SELECT
Id, ApiName, MasterLabel, IsActive, SortOrder, DefaultProbability
FROM
OpportunityStage';
}
if ($missingStageName) {
$escapedStageName = ValueNormalizer::replaceQueryWithStringLiterals($missingStageName);
$query .= ' WHERE ApiName = :stageName';
$params = [
'stageName' => $escapedStageName,
];
}
try {
$sfStages = $this->queryHandler->query($query, $params);
} catch (NoResultsException $exception) {
$sfStages = [];
}
$missingStage = null;
// Upsert all stages for the team.
foreach ($sfStages as $sfStage) {
$selectable = true;
if (array_key_exists('IsActive', $sfStage)) {
$selectable = $sfStage['IsActive'];
}
$this->restoreAnyTrashedEntity($this->config->stages(), $sfStage['Id']);
$stage = $this->config->stages()->updateOrCreate([
'crm_provider_id' => $sfStage['Id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($sfStage['ApiName'], 0, 50),
'label' => mb_strimwidth($sfStage['MasterLabel'], 0, 191),
'type' => $type,
'sequence' => $sfStage['SortOrder'] ?? 0,
'is_selectable' => $selectable,
'probability' => $sfStage['DefaultProbability'] ?? null,
]);
if ($missingStageName && $missingStageName === $sfStage['ApiName']) {
$missingStage = $stage;
}
}
if ($missingStageName && $missingStage === null) {
// If they requested a stage that still doesn't exist, it must be inactive so lazy create it.
$missingStage = $this->config->stages()->create([
'crm_provider_id' => Uuid::uuid4(),
'team_id' => $this->team->id,
'name' => mb_strimwidth($missingStageName, 0, 50),
'label' => mb_strimwidth($missingStageName, 0, 191),
'type' => $type,
'sequence' => 0,
'is_selectable' => 0,
]);
}
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int
{
$syncCount = 0;
$fields = $this->getAllFieldsAsArray('lead');
if (\in_array('Id', $fields, true) === false) {
return $syncCount;
}
$query = '
SELECT ' . rtrim(implode(',', $fields), ',') . '
FROM Lead
WHERE LastModifiedDate > :since
ORDER BY LastModifiedDate ASC';
try {
$sfLeads = $this->queryHandler->query($query, [
'since' => $since->format('Y-m-d\TH:i:s\Z'),
]);
foreach ($sfLeads as $sfLead) {
// Only sync if previously imported.
if ($this->hasLead($sfLead['Id'])) {
$this->importLead($sfLead);
$syncCount++;
}
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
$this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::LEAD);
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncLead(string $crmId): ?Lead
{
$fields = $this->getAllFieldsAsArray('lead');
$sfLead = $this->getRecord('Lead', $crmId, $fields);
return $this->importLead($sfLead);
}
private function importLead($crmData): ?Lead
{
/** @var ?Stage $stage */
$stage = null;
if (isset($crmData['Status'])) {
// Get the current stage.
$stage = $this->config
->stages()
->where('name', $crmData['Status'])
->where('type', Stage::TYPE_LEAD)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_LEAD], $crmData['Status']);
}
}
// If we have no way of importing this, just return null :(
if ($stage === null) {
return null;
}
$countryCode = $crmData['CountryCode'] ?? null;
// Salesforce allows custom "countries" to be created. Disregard these.
if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {
$countryCode = null;
}
// If we have no country code, try to parse it from the country name.
if ($countryCode === null && empty($crmData['Country']) !== false) {
$countryCode = $this->convertCountryNameToCode($crmData['Country']);
}
// Trim to our width and attempt to parse it.
$number = mb_strimwidth($crmData['Phone'] ?? '', 0, 25);
$parsedNumber = parsePhoneNumber($countryCode, $number);
$mobilePhone = null;
if (empty($crmData['MobilePhone']) === false) {
// Trim to our width and attempt to parse it.
$number = mb_strimwidth($crmData['MobilePhone'], 0, 25);
$mobilePhone = phone_e164($countryCode, $number);
}
$convertedDate = null;
$convertedAccount = null;
$convertedOpportunity = null;
$convertedContact = null;
if ($crmData['IsConverted'] == 'true') {
$convertedDate = $crmData['ConvertedDate'];
if (empty($crmData['ConvertedAccountId']) === false) {
$convertedAccount = $this->config
->accounts()
->where('crm_provider_id', $crmData['ConvertedAccountId'])
->first();
if ($convertedAccount === null) {
try {
$convertedAccount = $this->syncAccount($crmData['ConvertedAccountId']);
} catch (HttpNotFoundException $exception) {
// Probably the user has no permissions to access the converted data.
}
}
}
if (empty($crmData['ConvertedOpportunityId']) === false) {
$convertedOpportunity = $this->config
->opportunities()
->where('crm_provider_id', $crmData['ConvertedOpportunityId'])
->first();
if ($convertedOpportunity === null) {
try {
$convertedOpportunity = $this->syncOpportunity($crmData['ConvertedOpportunityId']);
} catch (HttpNotFoundException $exception) {
// Probably the user has no permissions to access the converted data.
}
}
}
if (empty($crmData['ConvertedContactId']) === false) {
$convertedContact = $this->team
->crm
->contacts()
->where('crm_provider_id', $crmData['ConvertedContactId'])
->first();
if ($convertedContact === null) {
try {
$convertedContact = $this->syncContact($crmData['ConvertedContactId']);
} catch (HttpNotFoundException $exception) {
// Probably the user has no permissions to access the converted data.
}
}
}
}
if (empty($crmData['Company'])) {
$company = 'Unknown';
} else {
$company = mb_strimwidth($crmData['Company'], 0, 191);
}
$domain = null;
if (empty($crmData['Website']) === false) {
$domain = mb_strimwidth($crmData['Website'], 0, 191);
$domain = StringUtil::resolveDomain($domain);
}
$createdDate = null;
if (empty($crmData['CreatedDate']) === false) {
$createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');
}
$profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);
$data = [
'team_id' => $this->team->id,
'user_id' => $profile?->user_id,
'owner_id' => $crmData['OwnerId'] ?? '',
'company' => $company,
'domain' => $domain,
'name' => $crmData['Name'] ? mb_strimwidth($crmData['Name'], 0, 191) : '',
'title' => $crmData['Title'] ? mb_strimwidth($crmData['Title'], 0, 128) : null,
'email' => $crmData['Email'] ? mb_strimwidth($crmData['Email'], 0, 80) : null,
'phone' => $parsedNumber['phone'],
'ext' => $parsedNumber['ext'] ?? null,
'mobile_phone' => $mobilePhone,
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: $crmData['Id'],
modelType: Lead::class,
fileName: $crmData['Id'],
avatarText: $crmData['Name']
),
'stage_id' => $stage->id,
'record_type_id' => null,
'converted_at' => $convertedDate,
'converted_account_id' => $convertedAccount->id ?? null,
'converted_opportunity_id' => $convertedOpportunity->id ?? null,
'converted_contact_id' => $convertedContact->id ?? null,
'country_code' => $countryCode,
'remotely_created_at' => $createdDate,
];
$this->restoreAnyTrashedEntity($this->config->leads(), $crmData['Id']);
/** @var Lead $lead */
$lead = $this->config->leads()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);
if ($lead->wasChanged('converted_at') && $lead->getConvertedAt() !== null) {
$this->eventDispatcher->dispatch(new LeadConverted($lead));
}
$this->handleObjectDeletion($lead, $crmData);
return $lead;
}
/**
* @inheritdoc
*/
public function syncAccounts(Carbon $since, ?Carbon $to = null): int
{
$syncCount = 0;
$fields = $this->getAllFieldsAsArray('account');
if (\in_array('Id', $fields, true) === false) {
return $syncCount;
}
$query = '
SELECT ' . rtrim(implode(',', $fields), ',') . '
FROM Account
WHERE LastModifiedDate > :since
ORDER BY LastModifiedDate ASC';
try {
$sfAccounts = $this->queryHandler->query($query, [
'since' => $since->format('Y-m-d\TH:i:s\Z'),
]);
foreach ($sfAccounts as $sfAccount) {
// Only sync if previously imported.
if ($this->hasAccount($sfAccount['Id'])) {
$this->importAccount($sfAccount);
$syncCount++;
}
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
$this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::ACCOUNT);
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncAccount(string $crmId): ?Account
{
$fields = $this->getAllFieldsAsArray('account');
if (! in_array('Id', $fields, true)) {
$this->logger->info('[Salesforce] Sync account cancelled. Fields are not available.', [
'crmId' => $crmId,
'userId' => $this->profile->getUserId(),
]);
return null;
}
$sfAccount = $this->getRecord('Account', $crmId, $fields);
return $this->importAccount($sfAccount);
}
private function importAccount($crmData): Account
{
$countryCode = $crmData['BillingCountryCode'] ?? $crmData['ShippingCountryCode'] ?? null;
// Salesforce allows custom "countries" to be created. Disregard these.
if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {
$countryCode = null;
}
// If we have no country code, try to parse it from the country names.
if ($countryCode === null && empty($crmData['BillingCountry']) === false) {
$countryCode = $this->convertCountryNameToCode($crmData['BillingCountry']);
}
if ($countryCode === null && empty($crmData['ShippingCountry']) === false) {
$countryCode = $this->convertCountryNameToCode($crmData['ShippingCountry']);
}
if (empty($crmData['Phone']) === false) {
// Trim to our width and attempt to parse it.
$number = mb_strimwidth($crmData['Phone'], 0, 25);
$parsedNumber = parsePhoneNumber($countryCode, $number);
} else {
$parsedNumber = [];
}
$industry = null;
if (empty($crmData['Industry']) === false) {
$industry = mb_strimwidth($crmData['Industry'], 0, 40);
}
$domain = null;
if (empty($crmData['Website']) === false) {
$domain = mb_strimwidth($crmData['Website'], 0, 191);
$domain = StringUtil::resolveDomain($domain);
}
$createdDate = null;
if (empty($crmData['CreatedDate']) === false) {
$createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');
}
$profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);
$data = [
'team_id' => $this->team->id,
'user_id' => $profile?->user_id,
'owner_id' => $crmData['OwnerId'],
'name' => mb_strimwidth($crmData['Name'], 0, 191),
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: $crmData['Id'],
modelType: Account::class,
fileName: $crmData['Id'],
avatarText: $crmData['Name']
),
'industry' => $industry,
'domain' => $domain,
'phone' => $parsedNumber['phone'] ?? null,
'ext' => $parsedNumber['ext'] ?? null,
'country_code' => $countryCode,
'remotely_created_at' => $createdDate,
];
$this->restoreAnyTrashedEntity($this->config->accounts(), $crmData['Id']);
/** @var Account $account */
$account = $this->config->accounts()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);
$this->handleObjectDeletion($account, $crmData);
return $account;
}
/**
* @inheritdoc
*/
public function syncOpportunities(array $parameters, ?string $strategy = null): int
{
$strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);
$syncCount = 0;
$logParams = $parameters;
$parameters['profile'] = $this->profile;
$logParams['user'] = $this->profile->getUserId();
if (count($strategies) > 1) {
$this->logger->warning('[' . $this->getDisplayName() . '] Multiple sync strategies used', [
'teamId' => $this->team->getUuid(),
'params' => $logParams,
'strategies_count' => count($strategies),
]);
}
foreach ($strategies as $syncStrategy) {
$name = $syncStrategy->getStrategyName();
try {
$sfOpportunities = $syncStrategy->fetchOpportunities($parameters);
$totalRecords = $sfOpportunities->count();
foreach ($sfOpportunities as $sfOpportunity) {
$this->importOpportunity($sfOpportunity);
$syncCount++;
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
$this->logger->warning('[' . $this->getDisplayName() . '] No opportunities found', [
'teamId' => $this->team->getUuid(),
'name' => $name,
'params' => $logParams,
'reason' => $noResultsException->getMessage(),
]);
} catch (CrmException $crmException) {
// Nothing to sync.
$this->logger->warning('[' . $this->getDisplayName() . '] Opportunity sync failed', [
'teamId' => $this->team->getUuid(),
'name' => $name,
'params' => $logParams,
'reason' => $crmException->getMessage(),
]);
}
}
$this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::OPPORTUNITY, ['params' => $logParams]);
// debug to see how if count of opportunities reaches 1000
if ($syncCount >= 1000) {
$this->logger->info(
'[' . $this->getDisplayName() . '] Sync Opportunities - count warning',
[
'team_id' => $this->team->getId(),
'params' => $logParams,
'count' => $syncCount,
'strategies_count' => count($strategies),
'total_records' => $totalRecords ?? null,
]
);
}
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncOpportunity(string $crmId): ?Opportunity
{
$strategy = $this->opportunitySyncStrategyResolver->resolve(
$this->config,
OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY
);
$parameters = [
'profile' => $this->profile,
'crm_id' => $crmId,
];
try {
$sfOpportunity = $strategy->fetchOpportunities($parameters);
} catch (HttpNotFoundException $e) {
$this->logger->info('[' . $this->getDisplayName() . '] Opportunity not found', [
'teamId' => $this->team->id_string,
'crmId' => $crmId,
]);
return null;
} catch (CrmException $crmException) {
$this->logger->info('[' . $this->getDisplayName() . '] Opportunity sync failed', [
'teamId' => $this->team->id_string,
'crmId' => $crmId,
'exception' => $crmException->getMessage(),
]);
return null;
}
if ($sfOpportunity instanceof ArrayIterator) {
return $this->importOpportunity($sfOpportunity->getItems());
}
return $this->importOpportunity($sfOpportunity);
}
/**
* @throws HttpNotFoundException
*/
private function importOpportunity($crmData): ?Opportunity
{
$profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);
$account = null;
if (empty($crmData['AccountId']) === false) {
/** @var ?Account $account */
$account = $this->config->accounts()
->where('crm_provider_id', (string) $crmData['AccountId'])
->first();
if ($account === null) {
$account = $this->syncAccount($crmData['AccountId']);
}
}
$userId = $profile?->getUserId() ?? $account?->getUserId();
if ($u...
|
[{"role":"AXStaticText","text& [{"role":"AXStaticText","text":"Diff","depth":1,"bounds":{"left":0.25531915,"top":0.39185953,"width":0.013962766,"height":0.013567438},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Changelist:","depth":1,"bounds":{"left":0.38231382,"top":0.15802075,"width":0.022938829,"height":0.027134877},"on_screen":true,"role_description":"text"},{"role":"AXPopUpButton","text":"Changelist:","depth":1,"bounds":{"left":0.40658244,"top":0.15802075,"width":0.042220745,"height":0.027134877},"on_screen":true,"role_description":"pop up button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Changes","depth":6,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Show Diff","depth":2,"bounds":{"left":0.25664893,"top":0.16201118,"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":"Rollback...","depth":2,"bounds":{"left":0.26529256,"top":0.16201118,"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":"Refresh","depth":2,"bounds":{"left":0.27393618,"top":0.16201118,"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":"Group By","depth":2,"bounds":{"left":0.28490692,"top":0.16201118,"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":"Expand All","depth":2,"bounds":{"left":0.3617021,"top":0.16201118,"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":"Collapse All","depth":2,"bounds":{"left":0.37034574,"top":0.16201118,"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":"AXStaticText","text":"1 modified","depth":1,"bounds":{"left":0.25531915,"top":0.254589,"width":0.1931516,"height":0.016759777},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"JY-209","depth":2,"bounds":{"left":0.2556516,"top":0.3008779,"width":0.19281915,"height":0.087789305},"on_screen":true,"value":"JY-209","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Commit Message","depth":1,"bounds":{"left":0.25531915,"top":0.28252193,"width":0.03557181,"height":0.013567438},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Commit Message History","depth":2,"bounds":{"left":0.4401596,"top":0.27853152,"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":"AXStaticText","text":"Git","depth":2,"bounds":{"left":0.45212767,"top":0.16280925,"width":0.0056515955,"height":0.013567438},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Author:","depth":2,"bounds":{"left":0.46010637,"top":0.18834797,"width":0.014960106,"height":0.013567438},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"Author:","depth":3,"bounds":{"left":0.4787234,"top":0.18834797,"width":0.09840426,"height":0.013567438},"on_screen":true,"role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Amend commit","depth":2,"bounds":{"left":0.4587766,"top":0.21069433,"width":0.039893616,"height":0.019952115},"on_screen":true,"help_text":"Merge this commit with the previous one","role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Sign-off commit","depth":2,"bounds":{"left":0.4587766,"top":0.23383878,"width":0.042220745,"height":0.019952115},"on_screen":true,"help_text":"<html>Adds the following line at the end of the commit message:<br/>Signed-off by: Lukas Kovalik <kovaliklukas@gmail.com></html>","role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Create extra commit with file movements","depth":2,"on_screen":false,"role_description":"checkbox","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Commit Checks","depth":2,"bounds":{"left":0.45212767,"top":0.27613726,"width":0.032247342,"height":0.013567438},"on_screen":true,"role_description":"text"},{"role":"AXCheckBox","text":"Update copyright","depth":2,"bounds":{"left":0.4587766,"top":0.29608938,"width":0.04488032,"height":0.019952115},"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Reformat code","depth":2,"bounds":{"left":0.4587766,"top":0.31923383,"width":0.03956117,"height":0.019952115},"on_screen":true,"help_text":"","role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Rearrange code","depth":2,"bounds":{"left":0.4587766,"top":0.3423783,"width":0.041888297,"height":0.019952115},"on_screen":true,"help_text":"","role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Optimize imports","depth":2,"bounds":{"left":0.4587766,"top":0.36552274,"width":0.044215426,"height":0.019952115},"on_screen":true,"help_text":"","role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Cleanup","depth":2,"bounds":{"left":0.4587766,"top":0.3886672,"width":0.026263298,"height":0.019952115},"on_screen":true,"help_text":"","role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"Choose profile","depth":2,"bounds":{"left":0.48902926,"top":0.39185953,"width":0.029920213,"height":0.013567438},"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Check malicious dependencies","depth":2,"bounds":{"left":0.4587766,"top":0.41181165,"width":0.07247341,"height":0.019952115},"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Run external formatter","depth":2,"bounds":{"left":0.4587766,"top":0.4349561,"width":0.05518617,"height":0.019952115},"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"Configure","depth":2,"bounds":{"left":0.51795214,"top":0.43814844,"width":0.019946808,"height":0.013567438},"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Perform SonarQube for IDE analysis","depth":2,"bounds":{"left":0.4587766,"top":0.45810056,"width":0.1200133,"height":0.019952115},"on_screen":true,"help_text":"","role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Find unused view files","depth":2,"bounds":{"left":0.4587766,"top":0.481245,"width":0.054521278,"height":0.019952115},"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Run Git hooks","depth":2,"on_screen":false,"help_text":"If unchecked, Git hooks will be skipped with the '--no-verify' option for the upcoming commit","role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Advanced Commit Checks","depth":2,"bounds":{"left":0.45212767,"top":0.5235435,"width":0.05418883,"height":0.013567438},"on_screen":true,"role_description":"text"},{"role":"AXCheckBox","text":"Analyze code","depth":2,"bounds":{"left":0.4587766,"top":0.5434956,"width":0.036901597,"height":0.019952115},"on_screen":true,"help_text":"","role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"Choose profile","depth":2,"bounds":{"left":0.49966756,"top":0.54668796,"width":0.029920213,"height":0.013567438},"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Check TODO","depth":2,"bounds":{"left":0.4587766,"top":0.5666401,"width":0.035904255,"height":0.019952115},"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"Configure","depth":2,"bounds":{"left":0.49867022,"top":0.5698324,"width":0.019946808,"height":0.013567438},"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"After Commit","depth":2,"bounds":{"left":0.45212767,"top":0.6089386,"width":0.027260639,"height":0.013567438},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Upload files to:","depth":2,"bounds":{"left":0.46010637,"top":0.6360734,"width":0.030585106,"height":0.013567438},"on_screen":true,"role_description":"text"},{"role":"AXPopUpButton","text":"Upload files to:","depth":2,"bounds":{"left":0.49168882,"top":0.62968874,"width":0.07679521,"height":0.027134877},"on_screen":true,"role_description":"pop up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"<None>","depth":7,"on_screen":false,"role_description":"text"},{"role":"AXCheckBox","text":"Always use selected server or group of servers","depth":2,"bounds":{"left":0.46675533,"top":0.66081405,"width":0.10538564,"height":0.019952115},"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Previous Difference","depth":2,"bounds":{"left":0.25664893,"top":0.41340783,"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 Difference","depth":2,"bounds":{"left":0.26529256,"top":0.41340783,"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":"Jump to Source","depth":2,"bounds":{"left":0.27393618,"top":0.41340783,"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":"Compare Previous File","depth":2,"bounds":{"left":0.28490692,"top":0.41340783,"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":"Compare Next File","depth":2,"bounds":{"left":0.29355052,"top":0.41340783,"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":"Go to Changed File…","depth":2,"bounds":{"left":0.30219415,"top":0.41340783,"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":"Side-by-side viewer","depth":2,"bounds":{"left":0.3118351,"top":0.41340783,"width":0.04720745,"height":0.01915403},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Do not ignore","depth":2,"bounds":{"left":0.36336437,"top":0.41340783,"width":0.03557181,"height":0.01915403},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Highlight words","depth":2,"bounds":{"left":0.40093085,"top":0.41340783,"width":0.03956117,"height":0.01915403},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse Unchanged Fragments","depth":2,"bounds":{"left":0.44148937,"top":0.41340783,"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":"Synchronize Scrolling","depth":2,"bounds":{"left":0.45013297,"top":0.41340783,"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":"Disable Editing","depth":2,"bounds":{"left":0.4587766,"top":0.41340783,"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":"Settings","depth":2,"bounds":{"left":0.46742022,"top":0.41340783,"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":"Help","depth":2,"bounds":{"left":0.47839096,"top":0.41340783,"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":"AXStaticText","text":"4 differences","depth":1,"bounds":{"left":0.5511968,"top":0.41181165,"width":0.027260639,"height":0.022346368},"on_screen":true,"role_description":"text"},{"role":"AXTextField","text":"1aee7aad","depth":1,"bounds":{"left":0.26196808,"top":0.43415803,"width":0.022606382,"height":0.013567438},"on_screen":true,"value":"1aee7aad","help_text":"text/plain","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Salesforce;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse Illuminate\\Database\\Eloquent\\Relations\\HasMany;\nuse Illuminate\\Events\\Dispatcher;\nuse Illuminate\\Support\\Facades\\Cache;\nuse Illuminate\\Support\\Facades\\Log;\nuse Illuminate\\Support\\Str;\nuse Jiminny\\Component\\Country\\CountriesMap;\nuse Jiminny\\Contracts\\Acl\\PermissionEnum;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\FetchRelatedActivityInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\ImportsBusinessProcessesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\SalesforceBatchSyncInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\SalesforceInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteNoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SearchTaskInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SendSummaryToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SupportsObjectTypeParseInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmProfileRecordTypesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Enums\\CrmObject;\nuse Jiminny\\Events\\Activities\\Crm\\LeadConverted;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpBadRequestException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Exceptions\\NoResultsException;\nuse Jiminny\\Exceptions\\ServiceUnavailableException;\nuse Jiminny\\Jobs\\Crm\\NoteObject;\nuse Jiminny\\Jobs\\Crm\\MatchActivitiesToNewOpportunity;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Calendar\\CalendarEvent;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Contracts\\ActivityContract;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Configuration;\nuse Jiminny\\Models\\Crm\\ContactRole;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Crm\\RecordType;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\TeamSettings;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\ContactRoleRepository;\nuse Jiminny\\Repositories\\Crm\\FieldRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Repositories\\Crm\\RecordTypeFieldValuesRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\Helpers\\ArrayIterator;\nuse Jiminny\\Services\\Crm\\MatchDomainByEmailInterface;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Services\\Crm\\ResolveCompanyNameByEmailTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\Fields\\FieldHelper;\nuse Jiminny\\Services\\Crm\\Salesforce\\Fields\\FieldTypeConverter;\nuse Jiminny\\Services\\Crm\\Salesforce\\Fields\\ValueNormalizer;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\FollowupActivityTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\LogActivityTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\RecordManipulationsTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\SyncFieldsTrait;\nuse Jiminny\\Utils\\CurrencyFormatter;\nuse Jiminny\\Utils\\StringUtil;\nuse Ramsey\\Uuid\\Uuid;\nuse Sentry\\Laravel\\Facade as Sentry;\n\nclass Service extends BaseService implements\n SalesforceInterface,\n SalesforceBatchSyncInterface,\n SyncCrmEntitiesInterface,\n SyncCrmProfileRecordTypesInterface,\n ImportsBusinessProcessesInterface,\n RemoteEntityManipulationInterface,\n FetchRelatedActivityInterface,\n SendSummaryToCrmInterface,\n MatchDomainByEmailInterface,\n SearchTaskInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n SupportsObjectTypeParseInterface,\n RemoteNoteEntityManipulationInterface,\n VerifyTaskExistsInterface\n{\n use ResolveCompanyNameByEmailTrait;\n use SyncFieldsTrait;\n use DeleteObjectsTrait;\n use RecordManipulationsTrait;\n use ServiceTraits\\BatchSyncTrait;\n use FollowupActivityTrait;\n use LogActivityTrait;\n\n /**\n * Note Body Limit for the Old Note-Taking Tool\n *\n * @var int\n */\n private const int CLASSIC_NOTE_MAX_LENGTH = 32000;\n\n /**\n * Note Content Limit for the New Notes\n *\n * @var int\n */\n private const int ENHANCED_NOTE_MAX_LENGTH = 50000000;\n\n private const string INSTALLED_PACKAGE_ID = '033Tw0000007bKbIAI';\n\n private const int CACHE_TTL = 600;\n\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day - 86400\n\n /**\n * @var Client\n */\n protected $client;\n\n protected PayloadBuilder $payloadBuilder;\n protected QueryHandler $queryHandler;\n\n private OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n\n public function __construct(\n Client $client,\n PayloadBuilder $payloadBuilder,\n protected Dispatcher $eventDispatcher,\n private readonly CountriesMap $countriesMap,\n private readonly ProspectPhotoPathService $prospectPhotoPathService,\n ) {\n parent::__construct();\n\n $this->client = $client;\n $this->payloadBuilder = $payloadBuilder;\n $this->queryHandler = app(QueryHandler::class, [\n 'client' => $this->client,\n 'logger' => $this->logger,\n ]);\n $this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n }\n\n public function getDisplayName(): string\n {\n return 'Salesforce';\n }\n\n public function getJobDelay(): int\n {\n return 1;\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n return $user->getSocialAccount(SocialAccount::PROVIDER_SALESFORCE);\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($activity, $crmProviderId) {\n $playbook = $this->getPlaybookFromActivity($activity);\n\n if ($playbook === null) {\n $this->logger->warning('[Salesforce] Cannot verify task - no playbook found', [\n 'activity' => $activity->getId(),\n 'crm_provider_id' => $crmProviderId,\n ]);\n\n return false;\n }\n\n $objectType = $playbook->getActivityType() === Playbook::ACTIVITY_TYPE_EVENT ? 'Event' : 'Task';\n\n try {\n $record = $this->getRecord($objectType, $crmProviderId, ['Id', 'IsDeleted']);\n\n return ! empty($record) && ($record['IsDeleted'] ?? false) === false;\n } catch (HttpNotFoundException|HttpBadRequestException) {\n $this->logger->info('[Salesforce] Activity record not found during verification', [\n 'activity' => $activity->getId(),\n 'object_type' => $objectType,\n 'crm_provider_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n });\n }\n\n public function query(string $queryToRun, array $parameters = []): QueryIterator\n {\n // Due to poorly designed external calls, this method cannot be entirely removed\n return $this->queryHandler->query($queryToRun, $parameters);\n }\n\n /*=========== Organization Information ===============*/\n\n /**\n * Get a list of all the API Versions for the instance.\n *\n * @throws CrmException\n *\n * @return mixed\n *\n */\n public function getApiVersions()\n {\n $url = $this->config->crm_base_url . '/services/data';\n\n $response = $this->client->get($url);\n\n return json_decode($response->getBody(), true);\n }\n\n /**\n * Gets the valid recordTypes for a given Salesforce Object via the describe API.\n */\n private function getRecordTypes(string $crmObject): array\n {\n $url = $this->client->getObjectsUrl() . $crmObject . '/describe';\n\n $response = $this->client->get($url);\n $jsonResponse = json_decode($response->getBody(), true);\n\n $fields = [];\n foreach ($jsonResponse['recordTypeInfos'] as $row) {\n $fields[] = ['recordTypeId' => $row['recordTypeId'], 'default' => $row['defaultRecordTypeMapping']];\n }\n\n return $fields;\n }\n\n /**\n * Convert raw field data into a format compatible with CRM APIs.\n */\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return ValueNormalizer::normalize($fieldType, $fieldValue, $internal);\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n $defaultFields = ($activityType === Playbook::ACTIVITY_TYPE_TASK)\n ? FieldDefinitions::defaultTaskFields()\n : FieldDefinitions::defaultEventFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n // Setup the activity field as the default Type.\n /** @var Field $activityField */\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'Type',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK, Playbook::ACTIVITY_TYPE_EVENT];\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n\n $fieldFilter = ($activityType === Playbook::ACTIVITY_TYPE_TASK)\n ? FieldDefinitions::taskFollowupFieldsFilter()\n : FieldDefinitions::eventFollowupFieldsFilter();\n\n foreach ($fieldFilter as $eachFilter) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $eachFilter);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n /**\n * This one is now called only when ImportActivityTypes is triggered or SyncFieldMetadata executed manually\n * Regular sync now uses SharedSyncFieldsTrait -> syncSingleObjectType\n * Needs to be replaced later on\n */\n public function syncField(Field $field): void\n {\n try {\n if (FieldHelper::isCustomField($field)) {\n $query = '\n SELECT\n Id, Metadata, TableEnumOrId\n FROM\n CustomField\n WHERE\n DeveloperName = :fieldName\n AND\n TableEnumOrId = :fieldType\n AND\n NamespacePrefix = :namespacePrefix';\n\n // We need to constrain the field lookup to the object, in case it's used in multiple places.\n $objectType = \\in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true)\n ? 'activity'\n : $field->object_type;\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'fieldName' => substr($field->crm_provider_id, 0, -\\strlen('__c')),\n 'fieldType' => ucfirst($objectType),\n\n // This is used to ensure we only consider the field within the org, not installed packages.\n 'namespacePrefix' => 'null',\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n // Sync field metadata.\n $metadata = $sfField['Metadata'];\n\n $field->description = mb_strimwidth($metadata['description'] ?? '', 0, 191);\n $field->label = mb_strimwidth($metadata['label'] ?? '', 0, Field::LABEL_MAX_LENGTH);\n $field->type = $this->convertFieldType($metadata['type'], $field->getEntityName());\n $field->is_mandatory = ($metadata['required'] === true);\n $field->length = $metadata['length'];\n $field->default_value = mb_strimwidth(trim($metadata['defaultValue'] ?? '', '\"'), 0, 191);\n $field->save();\n } else {\n $query = '\n SELECT\n Id, DataType, DeveloperName, Label, Length, Description\n FROM\n FieldDefinition\n WHERE\n DurableId = :entityName';\n\n $entityName = $field->getEntityName();\n $sfFields = $this->queryHandler->metadata($query, [\n 'entityName' => $entityName,\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n $convertedType = $this->convertFieldType($sfField['DataType'], $entityName);\n $label = mb_strimwidth($sfField['Label'], 0, Field::LABEL_MAX_LENGTH);\n\n if ($field->isBusinessType()) {\n $label = 'Opportunity Type';\n }\n\n $field->description = mb_strimwidth($sfField['Description'], 0, Field::DESCRIPTION_MAX_LENGTH);\n $field->label = $label;\n $field->type = $convertedType;\n $field->length = $sfField['Length'];\n $field->save();\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n private function convertFieldType(string $from, ?string $entityName = null): string\n {\n $converter = new FieldTypeConverter();\n\n return $converter->convert($from, $entityName);\n }\n\n /**\n * @inheritdoc\n */\n public function importPicklistValues(Field $field): array\n {\n $values = [];\n $fieldValues = [];\n\n try {\n if (FieldHelper::isCustomField($field)) {\n $query = '\n SELECT\n Id, Metadata, TableEnumOrId\n FROM\n CustomField\n WHERE\n DeveloperName = :fieldName\n AND\n TableEnumOrId = :fieldType\n AND\n NamespacePrefix = :namespacePrefix';\n\n // We need to constrain the field lookup to the object, in case it's used in multiple places.\n $objectType = \\in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true) ?\n 'activity' : $field->object_type;\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'fieldName' => substr($field->crm_provider_id, 0, -\\strlen('__c')),\n 'fieldType' => ucfirst($objectType),\n // This is used to ensure we only consider the field within the org, not installed packages.\n 'namespacePrefix' => 'null',\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n $valueSet = $sfField['Metadata']['valueSet'];\n\n if ($valueSet['valueSetName'] === null) {\n // Local picklist values can be obtained easily.\n $picklistValues = $valueSet['valueSetDefinition']['value'];\n } else {\n // But for some fields, we just get the Global Value Picklist pointer so need to do more work.\n $picklistValues = $this->importGlobalValuePicklistValues($valueSet['valueSetName']);\n }\n\n // Import all active values.\n foreach ($picklistValues as $i => $sfFieldValue) {\n // Setup default value.\n if ($sfFieldValue['default']) {\n $field->update(['default_value' => $sfFieldValue['valueName']]);\n }\n\n // This comes through as null if active (lol).\n if ($sfFieldValue['isActive'] !== false) {\n $values[] = [\n 'value' => $sfFieldValue['valueName'],\n 'label' => $sfFieldValue['valueName'],\n 'sequence' => $i,\n 'is_default' => $sfFieldValue['default'],\n ];\n }\n }\n } else {\n $objectFields = $this->getObjectFields($field->object_type);\n $fieldId = $field->crm_provider_id;\n\n // Only work with our field of interest.\n $objectField = array_filter($objectFields, function ($item) use ($fieldId) {\n return $item['name'] === $fieldId;\n });\n\n $objectField = array_shift($objectField);\n if (empty($objectField['picklistValues']) === false) {\n foreach ($objectField['picklistValues'] as $i => $sfFieldValue) {\n // Skip inactive values.\n if ($sfFieldValue['active'] === false) {\n continue;\n }\n\n // Setup default value.\n if ($sfFieldValue['defaultValue']) {\n $field->update(['default_value' => $sfFieldValue['value']]);\n }\n\n $values[] = [\n 'value' => $sfFieldValue['value'],\n 'label' => $sfFieldValue['label'],\n 'sequence' => $i,\n 'is_default' => $sfFieldValue['defaultValue'],\n ];\n }\n }\n }\n\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n $value['value'] = substr($value['value'] ?? '', 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n if (($key = array_search($value['value'], $fieldsToPurge, true)) !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n // Get IDs of the values to be deleted\n $valuesToDelete = $field->values()->whereIn('value', $fieldsToPurge);\n $valuesToDeleteIds = $valuesToDelete->pluck('id');\n if (! $valuesToDeleteIds->isEmpty()) {\n $recordTypeFieldValuesRepository = app(RecordTypeFieldValuesRepository::class);\n $recordTypeFieldValuesRepository->deleteForCrmFieldValueIds($valuesToDeleteIds->toArray());\n\n // Now safely delete from crm_field_values\n $valuesToDelete->delete();\n }\n\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n return $fieldValues;\n }\n\n /**\n * Gets values from Global Value Picklists.\n */\n private function importGlobalValuePicklistValues(string $picklistName): array\n {\n $query = '\n SELECT\n Metadata\n FROM\n GlobalValueSet\n WHERE\n DeveloperName = :picklistName\n LIMIT 1';\n\n try {\n $sfValues = $this->queryHandler->metadata($query, [\n 'picklistName' => $picklistName,\n ]);\n\n // There is always 1 result at this point.\n $sfValue = $sfValues->current();\n\n return $sfValue['Metadata']['customValue'];\n } catch (NoResultsException $noResultsException) {\n // Nothing returned.\n\n return [];\n }\n }\n\n /**\n * @inheritdoc\n */\n public function syncProfileRecordTypes(): void\n {\n $objectTypes = [\n 'lead',\n 'account',\n 'contact',\n 'opportunity',\n 'task',\n 'event',\n ];\n\n foreach ($objectTypes as $objectType) {\n try {\n $crmRecordTypes = $this->getRecordTypes(ucfirst($objectType));\n\n foreach ($crmRecordTypes as $crmRecordType) {\n // If the record type is default and not the Master type, set this.\n if ($crmRecordType['default'] && $crmRecordType['recordTypeId'] !== '012000000000000AAA') {\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $crmRecordType['recordTypeId'])\n ->first();\n\n if ($recordType) {\n $this->profile->{$objectType . '_record_type_id'} = $recordType->id;\n }\n }\n }\n } catch (HttpNotFoundException $exception) {\n Log::error('No access to ' . $objectType . ' object, skipping...');\n\n // XXX: should we log this fact somewhere?\n continue;\n }\n }\n\n if ($this->profile->isDirty()) {\n $this->profile->save();\n }\n }\n\n /**\n * Gets business processes.\n */\n public function importBusinessProcesses(): void\n {\n $query = '\n SELECT\n Id, IsActive, Name, TableEnumOrId\n FROM\n BusinessProcess\n WHERE\n TableEnumOrId IN (\\'Lead\\',\\'Opportunity\\')';\n\n try {\n $sfProcesses = $this->queryHandler->query($query);\n\n // Upsert all processes for the team.\n foreach ($sfProcesses as $sfProcess) {\n /** @var BusinessProcess $businessProcess */\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $sfProcess['Id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => $sfProcess['Name'],\n 'type' => $sfProcess['TableEnumOrId'] === 'Lead' ? 'lead' : 'opportunity',\n 'is_selectable' => $sfProcess['IsActive'],\n ]);\n\n $this->importBusinessProcessStages($businessProcess);\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n /**\n * Gets business process stages.\n */\n private function importBusinessProcessStages(BusinessProcess $businessProcess): void\n {\n $query = '\n SELECT\n Metadata\n FROM\n BusinessProcess\n WHERE\n Id = :processId';\n\n try {\n $stages = [];\n $sfProcessStages = $this->queryHandler->metadata($query, [\n 'processId' => $businessProcess->crm_provider_id,\n ]);\n\n // There is always 1 result at this point.\n $sfProcessStage = $sfProcessStages->current();\n\n // Upsert all processes for the team.\n foreach ($sfProcessStage['Metadata']['values'] as $sfProcessStage) {\n $sanitizedName = urldecode($sfProcessStage['valueName']); // Must decode: \"%2C\" becomes \",\" etc.\n\n $stage = $businessProcess->crm->stages()\n // This MUST match on label because this API doesn't use API Name.\n ->where('label', $sanitizedName)\n ->where('type', $businessProcess->type)\n ->where('is_selectable', 1)\n ->first();\n\n if ($stage) {\n $stages[] = $stage->id;\n }\n }\n\n $businessProcess->stages()->sync($stages);\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n /**\n * Gets record types.\n */\n public function importRecordTypes(): void\n {\n $query = '\n SELECT\n Id, IsActive, Name, BusinessProcessId, SobjectType\n FROM\n RecordType';\n\n try {\n $sfRecordTypes = $this->queryHandler->query($query);\n\n // Upsert all record types for the process.\n foreach ($sfRecordTypes as $sfRecordType) {\n $businessProcess = null;\n if ($sfRecordType['BusinessProcessId']) {\n $businessProcess = $this->config->businessProcesses()\n ->where('crm_provider_id', $sfRecordType['BusinessProcessId'])\n ->first();\n }\n\n /** @var RecordType $recordType */\n $recordType = $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $sfRecordType['Id'],\n ], [\n 'team_id' => $this->team->id,\n 'type' => mb_strtolower($sfRecordType['SobjectType']),\n 'name' => $sfRecordType['Name'],\n 'is_selectable' => $sfRecordType['IsActive'],\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n $this->importRecordTypeFieldValues($recordType);\n }\n } catch (NoResultsException $noResultsException) {\n // Do nothing.\n }\n }\n\n /**\n * Import record type - field value mappings. This only works for standard fields.\n */\n private function importRecordTypeFieldValues(RecordType $recordType): void\n {\n try {\n $query = '\n SELECT\n Metadata\n FROM\n RecordType\n WHERE\n Id = :recordTypeId';\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'recordTypeId' => $recordType->crm_provider_id,\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n // Sync field metadata.\n $picklists = $sfField['Metadata']['picklistValues'];\n\n foreach ($picklists as $picklist) {\n $field = $this->config->fields()->where([\n 'type' => Field::TYPE_PICKLIST,\n 'object_type' => $recordType->type,\n 'crm_provider_id' => $picklist['picklist'],\n ])->first();\n\n if ($field) {\n $fieldValues = [];\n\n foreach ($picklist['values'] as $value) {\n // Must decode: \"%2C\" becomes \",\" etc.\n $fieldValue = $field->values()\n ->where('value', urldecode($value['valueName']))\n ->first();\n\n if ($fieldValue) {\n $fieldValues[] = $fieldValue->id;\n }\n }\n\n $recordType->fieldValues()->sync($fieldValues);\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n /**\n * @inheritdoc\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $params = [];\n $missingStage = null;\n if ($types === null) {\n $types = [Stage::TYPE_LEAD, Stage::TYPE_OPPORTUNITY];\n }\n\n foreach ($types as $type) {\n if ($type === Stage::TYPE_LEAD) {\n $query = '\n SELECT\n Id, ApiName, MasterLabel, SortOrder\n FROM\n LeadStatus';\n } else {\n $query = '\n SELECT\n Id, ApiName, MasterLabel, IsActive, SortOrder, DefaultProbability\n FROM\n OpportunityStage';\n }\n\n if ($missingStageName) {\n $escapedStageName = ValueNormalizer::replaceQueryWithStringLiterals($missingStageName);\n\n $query .= ' WHERE ApiName = :stageName';\n\n $params = [\n 'stageName' => $escapedStageName,\n ];\n }\n\n try {\n $sfStages = $this->queryHandler->query($query, $params);\n } catch (NoResultsException $exception) {\n $sfStages = [];\n }\n\n $missingStage = null;\n\n // Upsert all stages for the team.\n foreach ($sfStages as $sfStage) {\n $selectable = true;\n if (array_key_exists('IsActive', $sfStage)) {\n $selectable = $sfStage['IsActive'];\n }\n\n $this->restoreAnyTrashedEntity($this->config->stages(), $sfStage['Id']);\n\n $stage = $this->config->stages()->updateOrCreate([\n 'crm_provider_id' => $sfStage['Id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($sfStage['ApiName'], 0, 50),\n 'label' => mb_strimwidth($sfStage['MasterLabel'], 0, 191),\n 'type' => $type,\n 'sequence' => $sfStage['SortOrder'] ?? 0,\n 'is_selectable' => $selectable,\n 'probability' => $sfStage['DefaultProbability'] ?? null,\n ]);\n\n if ($missingStageName && $missingStageName === $sfStage['ApiName']) {\n $missingStage = $stage;\n }\n }\n\n if ($missingStageName && $missingStage === null) {\n // If they requested a stage that still doesn't exist, it must be inactive so lazy create it.\n $missingStage = $this->config->stages()->create([\n 'crm_provider_id' => Uuid::uuid4(),\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($missingStageName, 0, 50),\n 'label' => mb_strimwidth($missingStageName, 0, 191),\n 'type' => $type,\n 'sequence' => 0,\n 'is_selectable' => 0,\n ]);\n }\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int\n {\n $syncCount = 0;\n $fields = $this->getAllFieldsAsArray('lead');\n if (\\in_array('Id', $fields, true) === false) {\n return $syncCount;\n }\n\n $query = '\n SELECT ' . rtrim(implode(',', $fields), ',') . '\n FROM Lead\n WHERE LastModifiedDate > :since\n ORDER BY LastModifiedDate ASC';\n\n try {\n $sfLeads = $this->queryHandler->query($query, [\n 'since' => $since->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($sfLeads as $sfLead) {\n // Only sync if previously imported.\n if ($this->hasLead($sfLead['Id'])) {\n $this->importLead($sfLead);\n $syncCount++;\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::LEAD);\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLead(string $crmId): ?Lead\n {\n $fields = $this->getAllFieldsAsArray('lead');\n\n $sfLead = $this->getRecord('Lead', $crmId, $fields);\n\n return $this->importLead($sfLead);\n }\n\n private function importLead($crmData): ?Lead\n {\n /** @var ?Stage $stage */\n $stage = null;\n if (isset($crmData['Status'])) {\n // Get the current stage.\n $stage = $this->config\n ->stages()\n ->where('name', $crmData['Status'])\n ->where('type', Stage::TYPE_LEAD)\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_LEAD], $crmData['Status']);\n }\n }\n\n // If we have no way of importing this, just return null :(\n if ($stage === null) {\n return null;\n }\n\n $countryCode = $crmData['CountryCode'] ?? null;\n\n // Salesforce allows custom \"countries\" to be created. Disregard these.\n if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {\n $countryCode = null;\n }\n\n // If we have no country code, try to parse it from the country name.\n if ($countryCode === null && empty($crmData['Country']) !== false) {\n $countryCode = $this->convertCountryNameToCode($crmData['Country']);\n }\n\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($crmData['Phone'] ?? '', 0, 25);\n $parsedNumber = parsePhoneNumber($countryCode, $number);\n\n $mobilePhone = null;\n if (empty($crmData['MobilePhone']) === false) {\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($crmData['MobilePhone'], 0, 25);\n $mobilePhone = phone_e164($countryCode, $number);\n }\n\n $convertedDate = null;\n $convertedAccount = null;\n $convertedOpportunity = null;\n $convertedContact = null;\n\n if ($crmData['IsConverted'] == 'true') {\n $convertedDate = $crmData['ConvertedDate'];\n\n if (empty($crmData['ConvertedAccountId']) === false) {\n $convertedAccount = $this->config\n ->accounts()\n ->where('crm_provider_id', $crmData['ConvertedAccountId'])\n ->first();\n\n if ($convertedAccount === null) {\n try {\n $convertedAccount = $this->syncAccount($crmData['ConvertedAccountId']);\n } catch (HttpNotFoundException $exception) {\n // Probably the user has no permissions to access the converted data.\n }\n }\n }\n\n if (empty($crmData['ConvertedOpportunityId']) === false) {\n $convertedOpportunity = $this->config\n ->opportunities()\n ->where('crm_provider_id', $crmData['ConvertedOpportunityId'])\n ->first();\n\n if ($convertedOpportunity === null) {\n try {\n $convertedOpportunity = $this->syncOpportunity($crmData['ConvertedOpportunityId']);\n } catch (HttpNotFoundException $exception) {\n // Probably the user has no permissions to access the converted data.\n }\n }\n }\n\n if (empty($crmData['ConvertedContactId']) === false) {\n $convertedContact = $this->team\n ->crm\n ->contacts()\n ->where('crm_provider_id', $crmData['ConvertedContactId'])\n ->first();\n\n if ($convertedContact === null) {\n try {\n $convertedContact = $this->syncContact($crmData['ConvertedContactId']);\n } catch (HttpNotFoundException $exception) {\n // Probably the user has no permissions to access the converted data.\n }\n }\n }\n }\n\n if (empty($crmData['Company'])) {\n $company = 'Unknown';\n } else {\n $company = mb_strimwidth($crmData['Company'], 0, 191);\n }\n\n $domain = null;\n if (empty($crmData['Website']) === false) {\n $domain = mb_strimwidth($crmData['Website'], 0, 191);\n $domain = StringUtil::resolveDomain($domain);\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $data = [\n 'team_id' => $this->team->id,\n 'user_id' => $profile?->user_id,\n 'owner_id' => $crmData['OwnerId'] ?? '',\n 'company' => $company,\n 'domain' => $domain,\n 'name' => $crmData['Name'] ? mb_strimwidth($crmData['Name'], 0, 191) : '',\n 'title' => $crmData['Title'] ? mb_strimwidth($crmData['Title'], 0, 128) : null,\n 'email' => $crmData['Email'] ? mb_strimwidth($crmData['Email'], 0, 80) : null,\n 'phone' => $parsedNumber['phone'],\n 'ext' => $parsedNumber['ext'] ?? null,\n 'mobile_phone' => $mobilePhone,\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['Id'],\n modelType: Lead::class,\n fileName: $crmData['Id'],\n avatarText: $crmData['Name']\n ),\n 'stage_id' => $stage->id,\n 'record_type_id' => null,\n 'converted_at' => $convertedDate,\n 'converted_account_id' => $convertedAccount->id ?? null,\n 'converted_opportunity_id' => $convertedOpportunity->id ?? null,\n 'converted_contact_id' => $convertedContact->id ?? null,\n 'country_code' => $countryCode,\n 'remotely_created_at' => $createdDate,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->leads(), $crmData['Id']);\n\n /** @var Lead $lead */\n $lead = $this->config->leads()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n if ($lead->wasChanged('converted_at') && $lead->getConvertedAt() !== null) {\n $this->eventDispatcher->dispatch(new LeadConverted($lead));\n }\n\n $this->handleObjectDeletion($lead, $crmData);\n\n return $lead;\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccounts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n $fields = $this->getAllFieldsAsArray('account');\n\n if (\\in_array('Id', $fields, true) === false) {\n return $syncCount;\n }\n\n $query = '\n SELECT ' . rtrim(implode(',', $fields), ',') . '\n FROM Account\n WHERE LastModifiedDate > :since\n ORDER BY LastModifiedDate ASC';\n\n try {\n $sfAccounts = $this->queryHandler->query($query, [\n 'since' => $since->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($sfAccounts as $sfAccount) {\n // Only sync if previously imported.\n if ($this->hasAccount($sfAccount['Id'])) {\n $this->importAccount($sfAccount);\n $syncCount++;\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::ACCOUNT);\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccount(string $crmId): ?Account\n {\n $fields = $this->getAllFieldsAsArray('account');\n if (! in_array('Id', $fields, true)) {\n $this->logger->info('[Salesforce] Sync account cancelled. Fields are not available.', [\n 'crmId' => $crmId,\n 'userId' => $this->profile->getUserId(),\n ]);\n\n return null;\n }\n\n $sfAccount = $this->getRecord('Account', $crmId, $fields);\n\n return $this->importAccount($sfAccount);\n }\n\n private function importAccount($crmData): Account\n {\n $countryCode = $crmData['BillingCountryCode'] ?? $crmData['ShippingCountryCode'] ?? null;\n\n // Salesforce allows custom \"countries\" to be created. Disregard these.\n if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {\n $countryCode = null;\n }\n\n // If we have no country code, try to parse it from the country names.\n if ($countryCode === null && empty($crmData['BillingCountry']) === false) {\n $countryCode = $this->convertCountryNameToCode($crmData['BillingCountry']);\n }\n\n if ($countryCode === null && empty($crmData['ShippingCountry']) === false) {\n $countryCode = $this->convertCountryNameToCode($crmData['ShippingCountry']);\n }\n\n if (empty($crmData['Phone']) === false) {\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($crmData['Phone'], 0, 25);\n $parsedNumber = parsePhoneNumber($countryCode, $number);\n } else {\n $parsedNumber = [];\n }\n\n $industry = null;\n if (empty($crmData['Industry']) === false) {\n $industry = mb_strimwidth($crmData['Industry'], 0, 40);\n }\n\n $domain = null;\n if (empty($crmData['Website']) === false) {\n $domain = mb_strimwidth($crmData['Website'], 0, 191);\n $domain = StringUtil::resolveDomain($domain);\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $data = [\n 'team_id' => $this->team->id,\n 'user_id' => $profile?->user_id,\n 'owner_id' => $crmData['OwnerId'],\n 'name' => mb_strimwidth($crmData['Name'], 0, 191),\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['Id'],\n modelType: Account::class,\n fileName: $crmData['Id'],\n avatarText: $crmData['Name']\n ),\n 'industry' => $industry,\n 'domain' => $domain,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'ext' => $parsedNumber['ext'] ?? null,\n 'country_code' => $countryCode,\n 'remotely_created_at' => $createdDate,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->accounts(), $crmData['Id']);\n\n /** @var Account $account */\n $account = $this->config->accounts()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n $this->handleObjectDeletion($account, $crmData);\n\n return $account;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOpportunities(array $parameters, ?string $strategy = null): int\n {\n $strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);\n\n $syncCount = 0;\n $logParams = $parameters;\n $parameters['profile'] = $this->profile;\n $logParams['user'] = $this->profile->getUserId();\n\n if (count($strategies) > 1) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Multiple sync strategies used', [\n 'teamId' => $this->team->getUuid(),\n 'params' => $logParams,\n 'strategies_count' => count($strategies),\n ]);\n }\n\n foreach ($strategies as $syncStrategy) {\n $name = $syncStrategy->getStrategyName();\n\n try {\n $sfOpportunities = $syncStrategy->fetchOpportunities($parameters);\n $totalRecords = $sfOpportunities->count();\n\n foreach ($sfOpportunities as $sfOpportunity) {\n $this->importOpportunity($sfOpportunity);\n $syncCount++;\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n $this->logger->warning('[' . $this->getDisplayName() . '] No opportunities found', [\n 'teamId' => $this->team->getUuid(),\n 'name' => $name,\n 'params' => $logParams,\n 'reason' => $noResultsException->getMessage(),\n ]);\n } catch (CrmException $crmException) {\n // Nothing to sync.\n $this->logger->warning('[' . $this->getDisplayName() . '] Opportunity sync failed', [\n 'teamId' => $this->team->getUuid(),\n 'name' => $name,\n 'params' => $logParams,\n 'reason' => $crmException->getMessage(),\n ]);\n }\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::OPPORTUNITY, ['params' => $logParams]);\n\n // debug to see how if count of opportunities reaches 1000\n if ($syncCount >= 1000) {\n $this->logger->info(\n '[' . $this->getDisplayName() . '] Sync Opportunities - count warning',\n [\n 'team_id' => $this->team->getId(),\n 'params' => $logParams,\n 'count' => $syncCount,\n 'strategies_count' => count($strategies),\n 'total_records' => $totalRecords ?? null,\n ]\n );\n }\n\n return $syncCount;\n }\n\n\n /**\n * @inheritdoc\n */\n public function syncOpportunity(string $crmId): ?Opportunity\n {\n $strategy = $this->opportunitySyncStrategyResolver->resolve(\n $this->config,\n OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY\n );\n\n $parameters = [\n 'profile' => $this->profile,\n 'crm_id' => $crmId,\n ];\n\n try {\n $sfOpportunity = $strategy->fetchOpportunities($parameters);\n } catch (HttpNotFoundException $e) {\n $this->logger->info('[' . $this->getDisplayName() . '] Opportunity not found', [\n 'teamId' => $this->team->id_string,\n 'crmId' => $crmId,\n ]);\n\n return null;\n } catch (CrmException $crmException) {\n $this->logger->info('[' . $this->getDisplayName() . '] Opportunity sync failed', [\n 'teamId' => $this->team->id_string,\n 'crmId' => $crmId,\n 'exception' => $crmException->getMessage(),\n ]);\n\n return null;\n }\n\n if ($sfOpportunity instanceof ArrayIterator) {\n return $this->importOpportunity($sfOpportunity->getItems());\n }\n\n return $this->importOpportunity($sfOpportunity);\n }\n\n /**\n * @throws HttpNotFoundException\n */\n private function importOpportunity($crmData): ?Opportunity\n {\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $account = null;\n if (empty($crmData['AccountId']) === false) {\n /** @var ?Account $account */\n $account = $this->config->accounts()\n ->where('crm_provider_id', (string) $crmData['AccountId'])\n ->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmData['AccountId']);\n }\n }\n\n $userId = $profile?->getUserId() ?? $account?->getUserId();\n if ($userId === null) {\n $this->logger->error('[Salesforce] | Skip import, no user_id found', [\n 'id' => $crmData['Id'],\n ]);\n\n return null;\n }\n\n /** @var ?Stage $stage */\n $stage = null;\n if (isset($crmData['StageName'])) {\n $stage = $this->config\n ->stages()\n ->where('name', $crmData['StageName'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->orderBy('is_selectable', 'DESC')\n ->orderBy('id')\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $crmData['StageName']);\n }\n }\n\n $recordType = null;\n if (empty($crmData['RecordTypeId']) === false) {\n /** @var ?RecordType $recordType */\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $crmData['RecordTypeId'])\n ->first();\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $closeDate = null;\n if (empty($crmData['CloseDate']) === false) {\n $closeDate = Carbon::parse($crmData['CloseDate'])->format('Y-m-d');\n }\n\n $valueFieldName = 'Amount';\n if ($this->config->opportunity_value_field_id) {\n $valueFieldName = $this->config->opportunityValueField->crm_provider_id;\n }\n\n $data = [\n 'team_id' => $this->team->id,\n 'account_id' => $account->id ?? null,\n 'user_id' => $userId,\n 'owner_id' => $crmData['OwnerId'] ?? null,\n 'name' => mb_strimwidth($crmData['Name'] ?? '', 0, 128),\n 'value' => $crmData[$valueFieldName],\n 'currency_code' => CurrencyFormatter::formatCode($crmData['CurrencyIsoCode'] ?? null),\n 'close_date' => $closeDate,\n 'is_closed' => $crmData['IsClosed'],\n 'is_won' => $crmData['IsWon'],\n 'stage_id' => $stage?->id ?? null,\n 'record_type_id' => $recordType->id ?? null,\n 'remotely_created_at' => $createdDate,\n 'probability' => $crmData['Probability'] ?? null,\n 'forecast_category' => $crmData['ForecastCategoryName'] ?? null,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->opportunities(), $crmData['Id']);\n\n // Do not allow locked DB tables & other errors\n // to interrupt the process of reverting the trashed opportunities\n try {\n /** @var Opportunity $opportunity */\n $opportunity = $this->config->opportunities()\n ->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n // import external fields into crm_field_data if present\n $crmFields = $this->getOpportunitySyncableFields();\n\n $this->importOpportunityCrmFieldData($crmData, $crmFields, $opportunity->id);\n\n $this->handleObjectDeletion($opportunity, $crmData);\n\n if ($opportunity->wasRecentlyCreated) {\n MatchActivitiesToNewOpportunity::dispatch($opportunity->getId());\n }\n\n return $opportunity;\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n $this->logger->error('[Salesforce] importOpportunity failure.', [\n 'crm_provider_id' => $crmData['Id'],\n 'team_id' => $this->team->id,\n 'exception' => $exception->getMessage(),\n ]);\n\n $this->handleEntityDeletionByProviderId($this->config->opportunities(), $crmData);\n }\n\n return null;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContacts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n $fields = $this->getAllFieldsAsArray('contact');\n if (\\in_array('Id', $fields, true) === false) {\n return $syncCount;\n }\n\n $query = '\n SELECT ' . rtrim(implode(',', $fields), ',') . '\n FROM Contact\n WHERE LastModifiedDate > :since\n ORDER BY LastModifiedDate ASC';\n\n try {\n $sfContacts = $this->queryHandler->query($query, [\n 'since' => $since->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($sfContacts as $sfContact) {\n // Only sync if previously imported.\n if ($this->hasContact($sfContact['Id'])) {\n $this->importContact($sfContact);\n $syncCount++;\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::CONTACT);\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContact(string $crmId): ?Contact\n {\n $fields = $this->getAllFieldsAsArray('contact');\n if (! in_array('Id', $fields, true)) {\n $this->logger->info('[Salesforce] Sync contact cancelled. Fields are not available.', [\n 'crmId' => $crmId,\n 'userId' => $this->profile->getUserId(),\n ]);\n\n return null;\n }\n\n $sfContact = $this->getRecord('Contact', $crmId, $fields);\n\n return $this->importContact($sfContact);\n }\n\n private function importContact($crmData): Contact\n {\n $account = null;\n // Contacts may not have accounts...\n if (isset($crmData['AccountId'])) {\n $account = $this->config->accounts()\n ->where('crm_provider_id', (string) $crmData['AccountId'])\n ->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmData['AccountId']);\n }\n }\n\n $countryCode = $crmData['MailingCountryCode'] ?? null;\n\n // Salesforce allows custom \"countries\" to be created. Disregard these.\n if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {\n $countryCode = null;\n }\n\n // If we have no country code, try to parse it from the country name.\n if ($countryCode === null && empty($crmData['MailingCountry']) === false) {\n $countryCode = $this->convertCountryNameToCode($crmData['MailingCountry']);\n\n if ($countryCode === null && $account) {\n $countryCode = $account->country_code;\n }\n }\n\n $ext = null;\n $parsedNumber = [];\n if (empty($crmData['Phone']) === false) {\n $number = Str::limit($crmData['Phone'], 25, '');\n $parsedNumber = parsePhoneNumber($countryCode, $number);\n\n if (empty($parsedNumber['ext']) === false) {\n $ext = Str::limit($parsedNumber['ext'], 10, '');\n }\n }\n\n $mobileNumber = null;\n if (empty($crmData['MobilePhone']) === false) {\n $mobileNumber = Str::limit(phone_e164($countryCode, $crmData['MobilePhone']), 25, '');\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $data = [\n 'team_id' => $this->team->id,\n 'account_id' => $account->id ?? null,\n 'user_id' => $profile?->user_id,\n 'owner_id' => $crmData['OwnerId'] ?? null,\n 'name' => ($crmData['Name'] ?? null) !== null ? mb_strimwidth($crmData['Name'], 0, 100) : '',\n 'title' => ($crmData['Title'] ?? null) !== null ? mb_strimwidth($crmData['Title'], 0, 128) : null,\n 'email' => ($crmData['Email'] ?? null) !== null ? mb_strimwidth($crmData['Email'], 0, 191) : null,\n 'country_code' => $countryCode,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'ext' => $ext,\n 'mobile_phone' => $mobileNumber,\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['Id'],\n modelType: Contact::class,\n fileName: $crmData['Id'],\n avatarText: $crmData['Name']\n ),\n 'remotely_created_at' => $createdDate,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->contacts(), $crmData['Id']);\n\n /** @var Contact $contact */\n $contact = $this->config->contacts()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n $this->handleObjectDeletion($contact, $crmData);\n\n return $contact;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n $fields = [\n 'InstanceName',\n 'OrganizationType',\n 'IsSandbox',\n ];\n\n $orgValues = $this->getRecord('Organization', $this->config->crm_provider_id, $fields);\n\n $edition = null;\n switch ($orgValues['OrganizationType']) {\n case 'Developer Edition':\n $edition = Configuration::EDITION_DEVELOPER;\n\n break;\n\n case 'Professional Edition':\n $edition = Configuration::EDITION_PROFESSIONAL;\n\n break;\n\n case 'Enterprise Edition':\n $edition = Configuration::EDITION_ENTERPRISE;\n\n break;\n }\n\n $this->config->edition = $edition;\n $this->config->instance = $orgValues['InstanceName'];\n\n // XXX: How can this state be possible?\n if ($this->config->version === null) {\n $this->config->version = Client::MIN_API_VERSION;\n }\n\n $installedVersion = $this->getInstalledAppVersion();\n if ($installedVersion !== null) {\n $installedVersion = (string) $this->getInstalledAppVersion();\n }\n\n $this->config->installed_app_version = $installedVersion;\n\n $this->config->save();\n }\n\n public function getInstalledAppVersion(): ?string\n {\n try {\n $query = '\n SELECT\n SubscriberPackageVersion.MajorVersion,\n SubscriberPackageVersion.MinorVersion,\n SubscriberPackageVersion.PatchVersion,\n SubscriberPackageVersion.BuildNumber\n FROM\n InstalledSubscriberPackage\n WHERE\n SubscriberPackageId = :packageId\n ';\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'packageId' => self::INSTALLED_PACKAGE_ID,\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n // Grab version number.\n $version = $sfField['SubscriberPackageVersion']['MajorVersion'] .\n $sfField['SubscriberPackageVersion']['MinorVersion'] .\n $sfField['SubscriberPackageVersion']['PatchVersion'] .\n $sfField['SubscriberPackageVersion']['BuildNumber'];\n } catch (\\Exception) {\n $version = null;\n }\n\n return $version;\n }\n\n /**\n * Store transcripts as note.\n *\n * @throws \\Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // For SF we also check if Log Notes is enabled.\n if ($this->profile->log_notes === Profile::LOG_NOTE_NONE) {\n return;\n }\n\n if ($activity->opportunity_id && $activity->prospect === null) {\n return;\n }\n\n try {\n $transcriptionData = $this->generateTranscription($activity);\n\n $noteMaxLength = $this->profile->log_notes === Profile::LOG_NOTE_ENHANCED\n ? self::ENHANCED_NOTE_MAX_LENGTH\n : self::CLASSIC_NOTE_MAX_LENGTH;\n\n $title = 'Transcript for ';\n $title .= $activity->title ?? $activity->activity_title;\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $body = mb_strimwidth($transcriptionData, 0, $noteMaxLength);\n\n if ($activity->opportunity_id) {\n $objectId = $activity->opportunity->crm_provider_id;\n } else {\n $objectId = $activity->prospect->crm_provider_id;\n }\n\n $noteId = $this->saveNote($title, $body, $objectId);\n\n // Store crm logged id in transcription.\n $transcription = $activity->getTranscription();\n $transcription->crm_activity_id = $noteId;\n $transcription->save();\n } catch (\\Exception $e) {\n \\Sentry::captureException($e);\n }\n }\n\n public function saveNote(string $title, string $body, string $objectId, ?NoteObject $noteObject = null): ?string\n {\n $noteId = null;\n\n try {\n if ($this->profile->log_notes === Profile::LOG_NOTE_ENHANCED) {\n $noteId = $this->buildEnhancedNote($title, $body, $objectId);\n } else {\n $noteId = $this->buildClassicNote($title, $body, $objectId);\n }\n } catch (HttpNotFoundException $exception) {\n // The profile not having access to create Enhanced Notes. Set their preference to Classic.\n if ($this->profile->log_notes === Profile::LOG_NOTE_ENHANCED) {\n $this->profile->update([\n 'log_notes' => Profile::LOG_NOTE_CLASSIC,\n ]);\n }\n }\n\n return $noteId;\n }\n\n /**\n * This is using the \"Enhanced\" Notes feature, NOT the \"Notes & Attachments\" feature being deprecated.\n *\n * @url https://salesforce.stackexchange.com/questions/104408/how-can-i-create-an-account-note-or-contact-note-via-api-that-is-visible-in-sale\n */\n private function buildEnhancedNote(string $title, string $body, string $objectId): string\n {\n // Decode stored entities, escape HTML (without quoting), then convert line breaks for Salesforce formatting\n $decodedBody = html_entity_decode($body, ENT_QUOTES | ENT_HTML5);\n $sanitizedBody = htmlspecialchars($decodedBody, ENT_NOQUOTES, 'UTF-8', false);\n $content = nl2br($sanitizedBody, false);\n $note = [\n 'OwnerId' => $this->profile->crm_provider_id,\n 'Title' => $title,\n 'Content' => base64_encode($content),\n ];\n\n $noteId = $this->createRecord('ContentNote', $note);\n\n $link = [\n 'ContentDocumentId' => $noteId,\n 'LinkedEntityId' => $objectId,\n 'ShareType' => 'I',\n ];\n\n $this->createRecord('ContentDocumentLink', $link);\n\n return $noteId;\n }\n\n private function buildClassicNote(string $title, string $body, string $objectId): string\n {\n if (in_array($this->parseObjectType($objectId), [Field::OBJECT_TASK, Field::OBJECT_EVENT])) {\n $this->logger->info('[Salesforce] Summary not sent', [\n 'profile_id' => $this->profile->id,\n 'objectId' => $objectId,\n 'reason' => 'Classical Note does not support Task/Event relation',\n ]);\n\n return '';\n }\n\n $titleTrimmed = null;\n\n if (mb_strlen($title) > 80) {\n $titleTrimmed = substr($title, 0, 77) . '...';\n }\n $payload = [\n 'OwnerId' => $this->profile->crm_provider_id,\n 'IsPrivate' => false,\n 'Title' => $titleTrimmed ?? $title,\n 'Body' => $titleTrimmed ? $title . PHP_EOL . $body : $body,\n 'ParentId' => $objectId,\n ];\n\n return $this->createRecord('Note', $payload);\n }\n\n /**\n * @inheritdoc\n */\n public function find(string $name, array $scopes): array\n {\n if ($this->profile === null) {\n return [];\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $limitValues = ['limit' => $this->limit, 'offset' => $this->offset];\n $sosl = $queryBuilder->buildFindQuery($name, $scopes, $limitValues);\n\n $this->logger->info('[Salesforce] Find prospects', [\n 'profile_id' => $this->profile->id,\n 'sosl_query' => $sosl,\n 'search_string' => $name,\n 'scopes' => $scopes,\n ]);\n\n $data = Cache::remember($this->profile->id . $sosl, self::CACHE_TTL, function () use ($sosl) {\n $data = [];\n\n try {\n // Hit remote API.\n $objects = $this->queryHandler->search($sosl);\n\n // Build mapped list.\n foreach ($objects as $object) {\n $type = strtolower($object['attributes']['type']);\n\n $record = [\n 'crmId' => $object['Id'],\n 'name' => $object['Name'],\n 'prospectType' => $type,\n 'phoneNumbers' => [],\n 'crmUrl' => $this->generateProviderUrl($object['Id'], $type),\n ];\n\n switch ($type) {\n case 'lead':\n if (empty($object['Company']) === false) {\n $record['organization'] = $object['Company'];\n }\n\n if (empty($object['Title']) === false) {\n $record['title'] = $object['Title'];\n }\n\n $stage = $this->config->stages()\n ->where('type', Stage::TYPE_LEAD)\n ->where('name', $object['Status'])\n ->first();\n\n // Lazy create the stage.\n if ($stage === null) {\n $stage = $this->importStages([Stage::TYPE_LEAD], $object['Status']);\n }\n\n if ($stage) {\n $record += [\n 'stage' => [\n 'id' => $stage->id_string,\n 'name' => $stage->name,\n ],\n ];\n }\n\n if (empty($object['RecordTypeId']) === false) {\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $object['RecordTypeId'])\n ->first();\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n }\n\n break;\n\n case 'account':\n if (empty($object['Industry']) === false) {\n $record['industry'] = $object['Industry'];\n $record['detailsLine'] = $object['Industry'];\n }\n if (! empty($object['PersonEmail'])) {\n $record['detailsLine'] = $object['PersonEmail'];\n }\n\n break;\n\n case 'contact':\n // For contacts, we should try and fetch their account name too.\n if ($object['AccountId']) {\n // Cheaper to get this locally.\n $account = $this->config->accounts()\n ->where('crm_provider_id', $object['AccountId'])\n ->first(['name']);\n\n if ($account) {\n $record['organization'] = $account->name;\n }\n }\n\n if (! empty($object['IsPersonAccount']) && $object['Email']) {\n $record['detailsLine'] = $object['Email'];\n } else {\n if (empty($object['Title']) === false) {\n $record['title'] = $object['Title'];\n }\n }\n\n break;\n }\n\n // Add phone numbers to record.\n if (empty($object['Phone']) === false && $object['Phone']) {\n $record['phoneNumbers'][] = [\n 'number' => $object['Phone'],\n 'nationalFormat' => phone_national($this->profile->user->country_code, $object['Phone']),\n 'type' => 'phone',\n ];\n }\n\n if (empty($object['MobilePhone']) === false && $object['MobilePhone']) {\n $record['phoneNumbers'][] = [\n 'number' => $object['MobilePhone'],\n 'nationalFormat' => phone_national(\n $this->profile->user->country_code,\n $object['MobilePhone']\n ),\n 'type' => 'mobile',\n ];\n }\n\n $data[] = $record;\n }\n } catch (NoResultsException $e) {\n $data = [];\n }\n\n return $data;\n });\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n\n if ($crmAccountId === null) {\n return $data;\n }\n\n if ($userId) {\n $profileRepository = app(ProfileRepository::class);\n $profile = $profileRepository->findProfileByUserId($this->config, $userId);\n\n $ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;\n }\n\n try {\n // Perhaps their profile has no opportunity permissions.\n if ($this->profile === null || $this->profile->opportunity_fields === null) {\n return $data;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $query = $queryBuilder->buildFindOpportunitiesQuery();\n\n $objects = $this->queryHandler->query($query, ['accountId' => $crmAccountId]);\n\n foreach ($objects as $object) {\n $record = [\n 'crmId' => $object['Id'],\n 'name' => $object['Name'],\n 'won' => $object['IsWon'],\n 'closed' => $object['IsClosed'],\n ];\n\n $valueFieldName = 'Amount';\n if ($this->config->opportunity_value_field_id) {\n $valueFieldName = $this->config->opportunityValueField->crm_provider_id;\n }\n\n if (empty($object[$valueFieldName]) === false) {\n $currency = $object['CurrencyIsoCode'] ?? $this->config->default_currency;\n $value = formatCurrency($object[$valueFieldName], $currency);\n\n $record += [\n 'value' => $value,\n ];\n }\n\n $stage = $this->config->stages()\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->where('name', $object['StageName'])\n ->first();\n\n // Lazy create the stage.\n if ($stage === null) {\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $object['StageName']);\n }\n\n $record += [\n 'stage' => [\n 'id' => $stage->id_string,\n 'name' => $stage->name,\n ],\n ];\n\n if (empty($object['RecordTypeId']) === false) {\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $object['RecordTypeId'])\n ->first();\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n }\n\n if ($ownerId && isset($object['OwnerId']) && $object['OwnerId'] === $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n } catch (NoResultsException $e) {\n return $data;\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n public function getContactRolesFromCrm(?Carbon $since = null): array\n {\n $roles = [];\n\n if ($this->profile === null) {\n return $roles;\n }\n\n $queryBuilder = app(QueryBuilder::class, ['profile' => $this->profile]);\n\n $query = $queryBuilder->buildGetContactRolesQuery($since);\n\n try {\n $objects = $this->queryHandler->query($query);\n\n foreach ($objects as $object) {\n $roles[] = [\n 'id' => $object['Id'],\n 'contactId' => $object['ContactId'],\n 'opportunityId' => $object['OpportunityId'],\n 'ownerId' => $object['Opportunity']['OwnerId'] ?? null,\n 'isPrimary' => $object['IsPrimary'],\n 'role' => $object['Role'],\n ];\n }\n } catch (NoResultsException) {\n // Just return an empty array.\n $this->logger->info('[Salesforce] No contact roles found', [\n 'since' => $since?->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n }\n\n return $roles;\n }\n\n public function syncContactRoles(Carbon $since): int\n {\n $contactRoleRepository = app(ContactRoleRepository::class);\n\n $crmContactRoles = $this->getContactRolesFromCrm(since: $since);\n $syncCount = 0;\n $contactRoles = [];\n\n foreach ($crmContactRoles as $crmContactRole) {\n $contactRoles[] = $this->importContactRole($crmContactRole);\n $syncCount++;\n }\n\n $contactRoleRepository->saveContactRoles($contactRoles);\n\n $this->syncRemotelyDeletedContactRoles();\n\n return $syncCount;\n }\n\n private function importContactRole(array $contactRole): array\n {\n $contact = $this->config->contacts()\n ->where('crm_provider_id', $contactRole['contactId'])\n ->first();\n\n if ($contact === null) {\n $contact = $this->syncContact($contactRole['contactId']);\n }\n\n $opportunity = $this->config->opportunities()\n ->where('crm_provider_id', $contactRole['opportunityId'])\n ->first();\n\n if ($opportunity === null) {\n $opportunity = $this->syncOpportunity($contactRole['opportunityId']);\n }\n\n $role = null;\n if (! empty($contactRole['role'])) {\n $role = mb_strimwidth($contactRole['role'], 0, 191);\n }\n\n return [\n 'crm_configuration_id' => $this->config->getId(),\n 'contact_id' => $contact->getId(),\n 'crm_provider_id' => $contactRole['id'],\n 'subject_type' => ContactRole::SUBJECT_TYPE_OPPORTUNITY,\n 'subject_id' => $opportunity->getId(),\n 'is_primary' => $contactRole['isPrimary'],\n 'role' => $role,\n ];\n }\n\n protected function syncRemotelyDeletedContactRoles(): bool\n {\n try {\n $deletedRemotely = $this->queryHandler->queryDeleted('OpportunityContactRole');\n } catch (NoResultsException $e) {\n return false;\n }\n\n $deletedOpportunities = $deletedRemotely->getResults();\n $deletedIds = array_column($deletedOpportunities, 'id');\n\n $contactRoleRepository = app(ContactRoleRepository::class);\n\n foreach (array_chunk($deletedIds, self::HARD_DELETE_CHUNK) as $chunk) {\n $contactRoleRepository->deleteContactRoles($chunk);\n\n $this->logger->info('[' . $this->getDisplayName() . '] Remotely deleted opportunities synced', [\n 'teamId' => $this->team->id_string,\n 'remotelyDeletedOpportunities' => $chunk,\n 'count' => count($chunk),\n ]);\n }\n\n return true;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = $objects = [];\n\n $hasWho = \\in_array($objectType, ['lead', 'contact']);\n $playbook = $this->getPlaybook($this->profile->user);\n $fields = array_merge(\n $this->profile->getFieldsAsArray(parent::OBJECT_TASK),\n $playbook && $playbook->activityField ? [$playbook->activityField->crm_provider_id] : []\n );\n\n // Query should default to any open call for that user.\n $query = '\n SELECT ' . implode(',', array_unique($fields)) . '\n FROM Task\n WHERE OwnerId = :ownerId\n AND IsArchived = false\n AND IsDeleted = false\n AND IsClosed = false\n AND (';\n\n if ($objectType === 'account') {\n // This covers tasks tied to a related contact or opportunity too.\n $query .= '\n AccountId = :accountId';\n }\n\n if ($hasWho) {\n $query .= '\n WhoId = :whoId';\n\n // If we are also going to check on a specific opportunity, set that up.\n if ($opportunityId) {\n $query .= ' OR WhatId = :whatId';\n }\n }\n\n $query .= ' ) ORDER BY LastModifiedDate DESC';\n\n try {\n $objects = $this->queryHandler->query($query, [\n 'ownerId' => $this->profile->crm_provider_id,\n 'whoId' => $objectId,\n 'whatId' => $opportunityId,\n 'accountId' => $objectId,\n ]);\n } catch (NoResultsException $e) {\n return $data;\n } finally {\n $this->logger->debug(sprintf('[Salesforce] Found %s tasks for query \"%s\"', count($objects), $query));\n }\n\n foreach ($objects as $object) {\n $dueDate = $object['ActivityDate'] ? Carbon::parse($object['ActivityDate'])->toIso8601String() : null;\n $data[] = [\n 'crmId' => $object['Id'],\n 'subject' => $object['Subject'],\n 'due' => $dueDate,\n 'type' => $object[$playbook->activityField->crm_provider_id],\n ];\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getEvents(string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = $objects = [];\n $user = $this->profile?->user;\n if ($this->profile === null || $user === null) {\n return $data;\n }\n\n $hasWho = \\in_array($objectType, ['lead', 'contact']);\n $playbook = $this->getPlaybook($user);\n $fields = array_merge(\n $this->profile->getFieldsAsArray(parent::OBJECT_EVENT),\n $playbook && $playbook->activityField ? [$playbook->activityField->crm_provider_id] : []\n );\n\n // Query should default to any event starting in the last week and ending up until today owned by the user.\n $query = '\n SELECT ' . implode(',', array_unique($fields)) . '\n FROM Event\n WHERE OwnerId = :ownerId\n AND IsArchived = false\n AND IsAllDayEvent = false\n AND StartDateTime >= LAST_N_DAYS:7\n AND EndDateTime <= TODAY\n AND (';\n\n if ($objectType === 'account') {\n // This covers events tied to a related contact or opportunity too.\n $query .= '\n AccountId = :accountId';\n }\n\n if ($hasWho) {\n $query .= '\n WhoId = :whoId';\n\n // If we are also going to check on a specific opportunity, set that up.\n if ($opportunityId) {\n $query .= ' OR WhatId = :whatId';\n }\n }\n\n $query .= ' ) ORDER BY LastModifiedDate DESC';\n\n try {\n $objects = $this->queryHandler->query($query, [\n 'ownerId' => $this->profile->crm_provider_id,\n 'whoId' => $objectId,\n 'whatId' => $opportunityId,\n 'accountId' => $objectId,\n ]);\n } catch (NoResultsException $e) {\n return $data;\n } finally {\n $this->logger->debug(sprintf('[Salesforce] Found %s tasks for query \"%s\"', count($objects), $query));\n }\n\n foreach ($objects as $object) {\n $dueDate = $object['StartDateTime'] ? Carbon::parse($object['StartDateTime'])->toIso8601String() : null;\n\n $data[] = [\n 'crmId' => $object['Id'],\n 'subject' => $object['Subject'],\n 'due' => $dueDate,\n 'type' => $object[$playbook->activityField->crm_provider_id],\n ];\n }\n\n return $data;\n }\n\n /**\n * Try to find CRM Objects using email address\n *\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchExactlyByEmail(string $email, ?int $userId = null): ?array\n {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $sosl = $queryBuilder->buildMatchByQuery($email, Field::TYPE_EMAIL);\n if ($sosl === null) {\n return null;\n }\n\n try {\n $objects = $this->queryHandler->search($sosl);\n $objects = $this->queryHandler->prioritiseResults(\n $objects,\n $email,\n QueryHandler::PRIORITISE_EMAIL\n );\n\n $data = $this->convertCrmData($objects, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (NoResultsException $e) {\n // Try the account next.\n if ($this->profile->account_fields === null) {\n return null;\n }\n }\n\n return null;\n }\n\n public function getDomain(string $email): ?string\n {\n // SF improved search - strip the domain extension, min domain name length 4\n return $this->getCompanyNameFromEmail(email: $email, minNameLength: 4);\n }\n\n /**\n * Try to find CRM objects using domain name of the email address\n *\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByDomain(string $domain, ?int $userId = null): ?array\n {\n $companyName = $domain;\n\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $sosl = $queryBuilder->buildMatchByDomainQuery($companyName);\n\n try {\n $objects = $this->queryHandler->search($sosl);\n\n $data = $this->convertCrmData($objects, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (NoResultsException) {\n return null;\n }\n }\n\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n // Don't bother looking up numbers that are masked.\n if (str_contains($phone, '**')) {\n return null;\n }\n\n if ($this->isPhoneNumberOfTeamMember($phone)) {\n return null;\n }\n\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $phoneNational = phone_national(null, $phone) ?? '';\n $possiblePhoneFormats = collect([\n preg_replace('/\\D/', '', ltrim($phone, '0+')),\n preg_replace('/\\D/', '', $phoneNational),\n formatDashPhoneNumber($phone),\n $phoneNational,\n ])\n ->filter() // Removes null and empty strings\n ->unique()\n ->values();\n\n foreach ($possiblePhoneFormats as $phone) {\n $sosl = $queryBuilder->buildMatchByQuery($phone, Field::TYPE_PHONE);\n if ($sosl === null) {\n continue;\n }\n\n try {\n $objects = $this->queryHandler->search($sosl);\n $objects = $this->queryHandler->prioritiseResults(\n $objects,\n $phone,\n QueryHandler::PRIORITISE_PHONE\n );\n\n return $this->convertCrmData($objects, $userId);\n } catch (NoResultsException) {\n continue;\n }\n }\n\n return null;\n }\n\n private function isPhoneNumberOfTeamMember(string $phone): bool\n {\n $teamRepository = app(TeamRepository::class);\n $user = $teamRepository->findTeamMemberByPhone($this->team, $phone);\n\n if ($user instanceof User) {\n return true;\n }\n\n return false;\n }\n\n protected function getCacheKey(string $object, ?int $userId = null): ?string\n {\n $key = $this->profile->id . $object;\n $keySuffix = $this->getOwnerKeySuffix($userId);\n\n return $key . $keySuffix;\n }\n\n private function getOwnerKeySuffix(?int $userId = null): string\n {\n return $userId === null ? '' : (string) $userId;\n }\n\n /** Determine the CRM Objects which represent the call activity. */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n if ($this->profile === null) {\n return null;\n }\n\n $cacheKey = $this->getCacheKey($name, $userId);\n\n $result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $sosl = $queryBuilder->buildMatchByQuery($name, 'name');\n if ($sosl === null) {\n return false;\n }\n\n try {\n $objects = $this->queryHandler->search($sosl);\n } catch (NoResultsException $e) {\n return false;\n }\n\n $objects = $this->queryHandler->prioritiseResults(\n $objects,\n $name,\n QueryHandler::PRIORITISE_NAME\n );\n\n $data = $this->convertCrmData($objects, $userId);\n\n return (! empty(array_filter($data))) ? $data : false;\n });\n\n return is_array($result) ? $result : null;\n }\n\n /**\n * @return array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n protected function convertCrmData(QueryIterator $objects, ?int $userId = null): array\n {\n $lead = null;\n $contact = null;\n $opportunity = null;\n $account = null;\n $stage = null;\n $countryCode = null;\n\n if ($objects->count() > 0) {\n $object = $objects->current();\n\n if ($object['attributes']['type'] === 'Lead') {\n $lead = $this->importLead($object);\n\n // Lead might not be imported if the Stage is null for example.\n if ($lead) {\n $countryCode = $lead->country_code;\n $stage = $lead->stage;\n }\n } else {\n if ($object['attributes']['type'] === 'Contact') {\n $contact = $this->importContact($object);\n $account = $contact->account;\n } else {\n $account = $this->importAccount($object);\n }\n\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account) {\n $countryCode = $account->country_code;\n }\n\n try {\n $sfOpportunities = $this->findOpportunities(\n $account?->getCrmProviderId(),\n $contact?->getCrmProviderId(),\n $userId\n );\n\n // Take the first opportunity, which will be ordered as priority based on their settings.\n if (! empty($sfOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($sfOpportunities[0]['crmId']);\n $stage = $opportunity?->stage;\n }\n } catch (Exception) {\n // Nothing to see here.\n }\n }\n }\n\n return [\n $lead,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n /**\n * @inheritdoc\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n if ($stage->type === Stage::TYPE_LEAD) {\n $objectType = 'Lead';\n $objectId = $crmObject->crm_provider_id;\n $objectStageType = 'Status';\n } else {\n $objectType = 'Opportunity';\n $objectId = $crmObject->crm_provider_id;\n $objectStageType = 'StageName';\n }\n\n $headers = [];\n if ($this->config->trigger_assignment_rules === false) {\n // @see: https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/headers_autoassign.htm\n $headers = [\n 'Sforce-Auto-Assign' => 'false',\n ];\n }\n\n $this->updateRecord($objectType, $objectId, [$objectStageType => $stage->name], $headers);\n }\n\n public function parseObjectType(string $objectId): string\n {\n if (Str::startsWith($objectId, '001')) {\n return 'account';\n }\n\n if (Str::startsWith($objectId, '003')) {\n return 'contact';\n }\n\n if (Str::startsWith($objectId, '00Q')) {\n return 'lead';\n }\n\n if (Str::startsWith($objectId, '006')) {\n return 'opportunity';\n }\n\n if (Str::startsWith($objectId, '00U')) {\n return 'event';\n }\n\n if (Str::startsWith($objectId, '00T')) {\n return 'task';\n }\n\n throw new \\InvalidArgumentException('Unsupported Object Type');\n }\n\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, ['profile' => $this->profile]);\n $query = $queryBuilder->buildGetUsersQuery($userToSearch);\n\n try {\n $salesforceUsers = $this->queryHandler->query($query, [\n 'active' => true,\n ]);\n } catch (NoResultsException $e) {\n $this->logger->info('[Salesforce] Sync Profiles. No users found', [\n 'query' => $query,\n 'error' => $e->getMessage(),\n ]);\n\n return null;\n }\n\n $teamRepository = app(TeamRepository::class);\n $customRules = $this->getCustomProfileRules($teamRepository);\n\n foreach ($salesforceUsers as $crmUser) {\n if ($crmUser['Email'] === null) {\n continue;\n }\n\n if (! $this->customProfileValidation($crmUser, $customRules)) {\n continue;\n }\n\n $user = $teamRepository->findActiveTeamMemberByEmail($this->team, $crmUser['Email']);\n\n if (! $user instanceof User) {\n continue;\n }\n\n $edition = $crmUser['UserPreferencesLightningExperiencePreferred']\n ? Profile::EDITION_LIGHTNING\n : Profile::EDITION_CLASSIC;\n\n $profileRepository = app(ProfileRepository::class);\n $profile = $profileRepository->updateOrCreateProfile(\n $user,\n [\n 'crm_configuration_id' => $this->config->getId(),\n 'crm_provider_id' => $crmUser['Id'],\n ],\n [\n 'user_id' => $user->getId(),\n 'edition' => $edition,\n 'has_external_cti' => ! empty($crmUser['CallCenterId']),\n 'crm_profile_id' => $crmUser['ProfileId'],\n ]\n );\n\n if ($userToSearch instanceof User && $userToSearch->getId() === $user->getId()) {\n return $profile;\n }\n }\n\n // Clean up inactive profiles\n try {\n $this->archiveInactiveProfiles();\n } catch (\\Exception $e) {\n $this->logger->warning('[Salesforce] Profile archiving failed', [\n 'teamId' => $this->team->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $url = null;\n\n // For Salesforce it's easy, we just point every object to the apex domain and they handle it.\n switch ($objectType) {\n case 'lead':\n case 'account':\n case 'contact':\n case 'opportunity':\n case 'task':\n case 'event':\n case 'activity':\n\n $url = $this->config->crm_base_url . '/' . $providerId;\n\n break;\n }\n\n return $url;\n }\n\n public function buildTaskSearchFields(): array\n {\n return ['Id', 'WhoId', 'WhatId', 'AccountId'];\n }\n\n public function getTaskByFilterConditions(\n array $fields,\n array $filters,\n bool $bulkSearch = false,\n bool $strictFilters = true\n ): ?array {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $query = $queryBuilder->buildSearchTaskQuery($fields, $filters, $bulkSearch, $strictFilters);\n\n try {\n if (! $bulkSearch) {\n $objects = $this->queryHandler->query($query, $filters);\n if ($objects->count() === 1) {\n return $objects->current();\n }\n }\n\n if ($bulkSearch) {\n $objects = $this->queryHandler->query($query);\n $records = [];\n foreach ($objects as $record) {\n $key = $record[end($fields)];\n $records[$key] = $record;\n }\n\n return $records;\n }\n } catch (\\Exception $e) {\n $this->logger->info('[Salesforce] Failed to execute query', [\n 'query' => $query,\n 'error' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function mapCrmObjects(array $task): array\n {\n $activityData = [];\n\n if (! empty($task['WhoId'])) {\n $type = $this->parseObjectType($task['WhoId']);\n $activityData[$type] = $task['WhoId'];\n }\n if (! empty($task['AccountId'])) {\n $activityData['account'] = $task['AccountId'];\n }\n if (! empty($task['WhatId'])) {\n $activityData['opportunity'] = $task['WhatId'];\n }\n\n return $activityData;\n }\n\n /**\n * Get SF task by Outreach call id.\n */\n public function getTaskByFilter(\n string $activityFieldType,\n array $filters,\n string $operator = '=',\n array $additionalFields = []\n ): ?array {\n $data = [];\n\n try {\n // Default (base) fields.\n $fields = ['Id', 'Subject', 'Description', 'ActivityDate', 'WhoId', 'WhatId', $activityFieldType];\n\n foreach ($additionalFields as $additionalField) {\n $fields[] = $additionalField->crm_provider_id;\n }\n\n $fields = array_unique($fields);\n\n // Find task with the same Outreach id as the call id.\n $query = 'SELECT ' . implode(',', $fields) . '\n FROM Task\n WHERE IsArchived = false AND IsDeleted = false';\n\n foreach ($filters as $key => $value) {\n $key = preg_quote($key, '/');\n $key = str_replace(['\\'', '\"'], '', $key);\n // Prepare the substitution.\n $strKey = \":$key\";\n\n $query .= \" AND $key $operator $strKey\";\n }\n\n $query .= ' ORDER BY LastModifiedDate DESC LIMIT 1';\n\n $objects = $this->queryHandler->query($query, $filters);\n\n // There should be only one task related to this call if any.\n if ($objects->count() === 1) {\n $object = $objects->current();\n\n $dueDate = $object['ActivityDate'] ? Carbon::parse($object['ActivityDate'])->toIso8601String() : null;\n\n $data = array_merge($object, [\n 'crmId' => $object['Id'],\n 'subject' => $object['Subject'],\n 'summary' => $object['Description'],\n 'due' => $dueDate,\n 'Type' => $object[$activityFieldType],\n ]);\n }\n } catch (NoResultsException $e) {\n // Filters don't match any records.\n } catch (ServiceUnavailableException $serviceUnavailableException) {\n // Service cannot be queried. We should probably log this.\n }\n\n return $data;\n }\n\n /**\n * Get Salesforce fields including datetime fields\n *\n * @param $objectType\n */\n private function getAllFieldsAsArray($objectType): array\n {\n $basicFields = [];\n // Not all users have access to all object fields.\n if ($this->profile->{$objectType . '_fields'}) {\n $basicFields = explode(',', $this->profile->{$objectType . '_fields'});\n }\n\n $extraFields = [\n 'CreatedDate',\n 'LastModifiedDate',\n 'IsDeleted',\n ];\n\n if ($objectType === self::OBJECT_OPPORTUNITY\n && $this->config->opportunity_value_field_id\n && ! in_array($this->config->opportunityValueField->crm_provider_id, $basicFields)\n ) {\n $extraFields[] = $this->config->opportunityValueField->crm_provider_id;\n }\n\n return array_unique(array_merge($basicFields, $extraFields));\n }\n\n /**\n * Generate transcription for activity description.\n */\n private function generateTranscription(Activity $activity): string\n {\n if (! ($this->config->store_transcript)) {\n // If sending transcription to activity toggle is disabled\n return '';\n }\n\n return $this->transcriptionService\n ->findTranscriptionByActivity($activity)\n ->map(static function (array $transcriptionSegment): string {\n return $transcriptionSegment['formattedStartsAt'] . ' | ' . $transcriptionSegment['transcript'];\n })\n ->implode(PHP_EOL);\n }\n\n /**\n * Find related Salesforce event based on activity data\n *\n * @return array<string>\n */\n public function fetchRelatedActivity(Activity $activity): array\n {\n $this->logger->info('[Salesforce] Searching for related activity', [\n 'activityId' => $activity->getUuid(),\n 'ownerId' => $this->profile?->crm_provider_id,\n ]);\n\n $sfEvent = $this->fetchRelatedEvent($activity);\n if (empty($sfEvent)) {\n $this->logger->info('[Salesforce] No related activity found', [\n 'activityId' => $activity->getUuid(),\n 'ownerId' => $this->profile?->crm_provider_id,\n 'account' => $activity->hasAccount()\n ? $activity->getAccount()->getCrmProviderId()\n : null,\n ]);\n\n return [];\n }\n\n return $sfEvent;\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n if ($activity->isTypeConference() === false) {\n return null;\n }\n\n if ($activity->hasActualStartTime() === false && $activity->hasScheduledStartTime() === false) {\n return null;\n }\n\n if (! $activity->hasProspect()) {\n $this->logger->info('[Salesforce] Skip look up, Activity not linked to Lead, Contact or Account', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n $playbook = $this->getPlaybook($activity->getUser());\n if ($playbook !== null && $playbook->getActivityType() === Playbook::ACTIVITY_TYPE_TASK) {\n $this->logger->info('[Salesforce] Skip auto-sync for task-based playbook', [\n 'activityUuid' => $activity->getUuid(),\n 'playbookId' => $playbook->getId(),\n 'playbookType' => $playbook->getActivityType(),\n ]);\n\n return null;\n }\n\n try {\n $sfEvent = $this->fetchRelatedActivity($activity);\n if (empty($sfEvent)) {\n return null;\n }\n\n [$activityField, $activityType] = $this->resolveActivityTypeFromEvent($activity, $sfEvent);\n\n $this->logger->info('[Salesforce] Found related activity', [\n 'activityId' => $activity->getUuid(),\n 'sfEvent' => $sfEvent['Id'],\n 'activityFieldName' => $activityField,\n 'crmActivityType' => ($activityField !== null && isset($sfEvent[$activityField]))\n ? $sfEvent[$activityField]\n : null,\n 'activityType' => $activityType,\n ]);\n\n $userId = $this->findRelatedActivityUserId($activity, $sfEvent);\n\n if ($activity->getUserId() !== $userId) {\n $this->logger->info('[Salesforce] Updating meeting owner', [\n 'activityId' => $activity->getUuid(),\n 'oldUserId' => $activity->getUserId(),\n 'newUserId' => $userId,\n ]);\n }\n\n $this->updateSfEventDescription($activity, $sfEvent);\n\n $activity->update([\n 'user_id' => $userId,\n 'crm_provider_id' => $sfEvent['Id'],\n 'playbook_category_id' => $activityType->id ?? $activity->getCategory()?->getId(),\n ]);\n\n $this->logger->info('[Salesforce] Activity updated', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return $activity;\n } catch (\\Exception $exception) {\n \\Sentry::captureException($exception);\n\n throw $exception;\n }\n }\n\n /**\n * @param array<string, mixed> $sfEvent\n *\n * @return array{0: string|null, 1: mixed}\n */\n private function resolveActivityTypeFromEvent(Activity $activity, array $sfEvent): array\n {\n $activityField = $this->getActivityFieldName($activity);\n $activityType = null;\n\n if ($activityField !== null && ! empty($sfEvent[$activityField])) {\n $playbook = $this->getPlaybook($activity->getUser());\n $activityType = $this->getPlaybookCategory($playbook, strval($sfEvent[$activityField]));\n }\n\n return [$activityField, $activityType];\n }\n\n /**\n * @param array<string> $sfEvent\n */\n private function findRelatedActivityUserId(Activity $activity, array $sfEvent): int\n {\n $userId = $activity->getUserId();\n\n if (empty($sfEvent['OwnerId']) === false) {\n $profile = $this\n ->config\n ->profiles()\n ->where('crm_provider_id', $sfEvent['OwnerId'])\n ->get()\n ->filter(static function (Profile $profile) use ($activity): bool {\n if (! $activity->isTypeConference()) {\n return ! empty($profile->user) ? $profile->user->isStatusActive() : false;\n }\n\n $participants = $activity->getParticipants();\n\n return ! empty($profile->user)\n ? $profile->user->isStatusActive()\n && $profile->user->hasPermission(PermissionEnum::RECORD_MEETING)\n && $participants->contains('user_id', $profile->user_id)\n : false;\n })\n ->first();\n\n if ($profile) {\n $userId = $profile->user_id;\n }\n }\n\n return $userId;\n }\n\n /**\n * @param array<string, mixed> $sfEvent\n */\n private function updateSfEventDescription(Activity $activity, array $sfEvent): void\n {\n try {\n if (str_contains($sfEvent['Description'], $activity->id_string)) {\n return;\n }\n\n $payload = [\n 'Description' => $sfEvent['Description']\n . PHP_EOL\n . PHP_EOL\n . (new DecorateActivity())->generateDescription($activity),\n ];\n\n $this->logger->info('[Salesforce] Update record', [\n 'activityId' => $activity->getUuid(),\n 'sfEvent' => $sfEvent['Id'],\n 'payload' => $payload,\n ]);\n\n $payload = array_merge(\n $payload,\n $this->payloadBuilder->fetchCustomFieldData($activity, Field::OBJECT_EVENT)\n );\n\n $this->updateRecord('Event', $sfEvent['Id'], $payload);\n } catch (\\Exception) {\n $this->logger->error('[Salesforce] Failed to update record', [\n 'activityUuid' => $activity->getUuid(),\n 'sfEvent' => $sfEvent['Id'],\n ]);\n }\n }\n\n /**\n * Returns the most recently modified Event within time range (if any).\n *\n * @return array|null An Event record from Salesforce.\n */\n private function fetchRelatedEvent(Activity $activity): ?array\n {\n $ownerId = $this->profile?->crm_provider_id;\n if ($ownerId === null) {\n return [];\n }\n\n /** @var ?Carbon $from */\n /** @var ?Carbon $to */\n [$from, $to] = $this->getFromToDates($activity);\n\n try {\n $whoId = null;\n $hasWho = $activity->lead_id || $activity->contact_id;\n if ($hasWho) {\n $whoId = $activity->hasLead()\n ? $activity->getLead()->crm_provider_id\n : $activity->getContact()->crm_provider_id;\n }\n\n if ($hasWho === false && $activity->account_id === null) {\n return null;\n }\n\n $query = $this->buildFetchRelatedEventQuery($activity);\n\n $objects = $this->queryHandler->query($query, [\n 'ownerId' => $ownerId,\n 'whoId' => $whoId,\n 'whatId' => $activity->hasOpportunity() ? $activity->getOpportunity()->crm_provider_id : null,\n 'accountId' => $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null,\n 'from' => $from?->format('Y-m-d\\TH:i:s\\Z'),\n 'to' => $to?->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($objects as $object) {\n return $object;\n }\n } catch (NoResultsException $e) {\n return [];\n }\n\n return [];\n }\n\n private function getFromToDates(Activity $activity): array\n {\n $from = null;\n $to = null;\n\n /** @var ?CalendarEvent $calendarEvent */\n $calendarEvent = $activity->calendarEvent()->first();\n if ($calendarEvent !== null) {\n $from = $calendarEvent->getStartTime();\n $to = $calendarEvent->getEndTime();\n }\n\n // For non-calendar imported activities\n // Also double check if calendar event dates could be null?\n // If null use what we've got so far\n if ($from === null || $to === null) {\n $from = $activity->hasScheduledStartTime()\n ? $activity->getScheduledStartTime()\n : $activity->getActualStartTime();\n $to = $activity->hasScheduledEndTime()\n ? $activity->getScheduledEndTime()->addMinutes(15)\n : $activity->getActualEndTime();\n }\n\n return [$from, $to];\n }\n\n /**\n * Determines the appropriate activity field name for querying Salesforce events.\n *\n * This method follows a hierarchy to determine the field name:\n * 1. Uses the playbook's activity field if it exists and is in the profile's accessible fields\n * 2. Falls back to the default activity field if the profile has no event fields configured\n * 3. Returns null if no suitable field is found\n *\n * @param Activity $activity The activity to determine the field for\n *\n * @return string|null The field name to use in queries, or null if none is available\n */\n private function getActivityFieldName(Activity $activity): ?string\n {\n if ($this->profile === null) {\n $this->logger->warning('[Salesforce] Cannot determine activity field - profile not found', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n $profileEventFields = $this->profile->getFieldsAsArray('event');\n\n if (empty($profileEventFields)) {\n $defaultActivityField = $this->getDefaultActivityField(Field::OBJECT_EVENT);\n $defaultFieldName = $defaultActivityField?->getAttribute('crm_provider_id');\n // Profile not yet synced — fall back to the default activity field.\n // There is a small chance that the profile won't have Default Activity Type field access\n // in which case the query will fail.\n // This is however an edge case and should be reviewed for profile sync issues.\n Sentry::withScope(function (\\Sentry\\State\\Scope $scope) use ($defaultFieldName): void {\n $scope->setContext('details', [\n 'profileId' => $this->profile->id,\n 'defaultField' => $defaultFieldName,\n ]);\n Sentry::captureMessage(\n '[Salesforce] Profile event fields empty, falling back to default activity field.',\n \\Sentry\\Severity::warning()\n );\n });\n\n return $defaultFieldName;\n }\n\n $playbook = $this->getPlaybook($activity->getUser());\n\n if (! is_null($playbook) && ! is_null($playbook->getActivityField())) {\n $playbookFieldName = $playbook->getActivityField()->getAttribute('crm_provider_id');\n\n if (in_array($playbookFieldName, $profileEventFields, true)) {\n return $playbookFieldName;\n }\n\n $this->logger->warning('[Salesforce] Playbook activity field not found in profile fields', [\n 'activityId' => $activity->getUuid(),\n 'playbookField' => $playbookFieldName,\n 'profileId' => $this->profile->id,\n ]);\n }\n\n return null;\n }\n\n private function buildFetchRelatedEventQuery(Activity $activity): string\n {\n $hasWho = $activity->lead_id || $activity->contact_id;\n\n $activityFieldName = $this->getActivityFieldName($activity);\n $fields = array_filter(['Id', 'Description', 'OwnerId', $activityFieldName]);\n\n $ownerCondition = '(OwnerId = :ownerId OR CreatedById = :ownerId)';\n\n $query = '\n SELECT ' . implode(',', $fields) . '\n FROM Event\n WHERE ' . $ownerCondition . '\n AND IsArchived = false\n AND IsAllDayEvent = false\n AND StartDateTime >= :from\n AND EndDateTime <= :to\n AND (';\n\n $operator = '';\n if ($activity->account_id) {\n // This covers events tied to a related contact or opportunity too.\n $query .= 'AccountId = :accountId';\n\n $operator = ' OR ';\n }\n\n if ($hasWho) {\n $query .= $operator . 'WhoId = :whoId';\n\n // If we are also going to check on a specific opportunity, set that up.\n if ($activity->opportunity_id) {\n $query .= ' OR WhatId = :whatId';\n }\n }\n\n $query .= ') ORDER BY LastModifiedDate DESC';\n\n return $query;\n }\n\n public function fetchProspect(array $task): array\n {\n $lead = $account = $opportunity = $contact = $stage = $countryCode = null;\n $externalId = $task['WhoId'] ?? null;\n\n // Lead or Contact\n if ($externalId) {\n try {\n [$lead, $account, $opportunity, $contact, $stage, $countryCode] = $this->parseRecords($externalId);\n } catch (\\InvalidArgumentException $exception) {\n // Invalid object type.\n }\n }\n\n // If we happen to know the opportunity or account from the Task, figure that out.\n if (empty($task['WhatId']) === false) {\n // WhatId could be either Account ID or Opportunity ID.\n // If WhatId is Opportunity ID, get the opportunity and stage from the CRM.\n try {\n [, $account, $opportunity, , $stage, ] = $this->parseRecords($task['WhatId']);\n } catch (\\InvalidArgumentException $exception) {\n // Invalid object type.\n }\n }\n\n return [$lead, $account, $opportunity, $contact, $stage, $countryCode];\n }\n\n /**\n * Save activity transcription summary as note\n */\n public function saveTranscriptionSummaryAsNote(\n ActivityContract $activity,\n string $title,\n string $body,\n ?string $objectId,\n ?NoteObject $noteObject = null,\n ): ?string {\n return $this->saveNote($title, $body, (string) $objectId);\n }\n\n public function getObjectByFilterConditions(string $objectType, array $fields, array $filters): ?array\n {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $query = $queryBuilder->buildObjectSearchQuery($objectType, $fields, $filters);\n\n try {\n $objects = $this->queryHandler->query($query, $filters);\n if ($objects->count() === 1) {\n return $objects->current();\n }\n } catch (\\Exception $e) {\n $this->logger->info('[Salesforce] Failed to execute query', [\n 'query' => $query,\n 'error' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n private function getCustomProfileRules(TeamRepository $teamRepository): array\n {\n $teamSettings = $teamRepository->getTeamSetting($this->team, 'custom_profile_validation');\n\n if ($teamSettings instanceof TeamSettings && $teamSettings->getValueType() === 'array') {\n $customRules = json_decode($teamSettings->getValue(), true);\n if (is_array($customRules)) {\n return $customRules;\n }\n }\n\n return [];\n }\n\n private function customProfileValidation(array $crmUser, array $customRules): bool\n {\n foreach ($customRules as $customRule) {\n if ($crmUser[$customRule['field']] !== $customRule['value']) {\n return false;\n }\n }\n\n return true;\n }\n\n /**\n * When syncing Contact / Lead / Account / Opportunity / Stage crm entities,\n * validate and restore locally trashed objects,\n * before updating them. Objects are identified by CrmProviderId\n */\n private function restoreAnyTrashedEntity(HasMany $targetEntity, string $crmProviderId): void\n {\n $recordExists = $targetEntity->withTrashed()->where(['crm_provider_id' => $crmProviderId])->first();\n if ($recordExists && $recordExists->trashed()) {\n $recordExists->restore();\n }\n }\n\n #[\\Override] public function supportsNotes(): bool\n {\n return true;\n }\n\n private function getOwnerProfile(?string $ownerId): ?Profile\n {\n if ($ownerId === null) {\n return null;\n }\n\n return $this->config->profiles()\n ->where('crm_provider_id', $ownerId)\n ->first();\n }\n}","depth":2,"bounds":{"left":0.25531915,"top":0.42697525,"width":0.29089096,"height":0.57302475},"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Salesforce;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse Illuminate\\Database\\Eloquent\\Relations\\HasMany;\nuse Illuminate\\Events\\Dispatcher;\nuse Illuminate\\Support\\Facades\\Cache;\nuse Illuminate\\Support\\Facades\\Log;\nuse Illuminate\\Support\\Str;\nuse Jiminny\\Component\\Country\\CountriesMap;\nuse Jiminny\\Contracts\\Acl\\PermissionEnum;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\FetchRelatedActivityInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\ImportsBusinessProcessesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\SalesforceBatchSyncInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\SalesforceInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteNoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SearchTaskInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SendSummaryToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SupportsObjectTypeParseInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmProfileRecordTypesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Enums\\CrmObject;\nuse Jiminny\\Events\\Activities\\Crm\\LeadConverted;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpBadRequestException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Exceptions\\NoResultsException;\nuse Jiminny\\Exceptions\\ServiceUnavailableException;\nuse Jiminny\\Jobs\\Crm\\NoteObject;\nuse Jiminny\\Jobs\\Crm\\MatchActivitiesToNewOpportunity;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Calendar\\CalendarEvent;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Contracts\\ActivityContract;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Configuration;\nuse Jiminny\\Models\\Crm\\ContactRole;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Crm\\RecordType;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\TeamSettings;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\ContactRoleRepository;\nuse Jiminny\\Repositories\\Crm\\FieldRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Repositories\\Crm\\RecordTypeFieldValuesRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\Helpers\\ArrayIterator;\nuse Jiminny\\Services\\Crm\\MatchDomainByEmailInterface;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Services\\Crm\\ResolveCompanyNameByEmailTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\Fields\\FieldHelper;\nuse Jiminny\\Services\\Crm\\Salesforce\\Fields\\FieldTypeConverter;\nuse Jiminny\\Services\\Crm\\Salesforce\\Fields\\ValueNormalizer;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\FollowupActivityTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\LogActivityTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\RecordManipulationsTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\SyncFieldsTrait;\nuse Jiminny\\Utils\\CurrencyFormatter;\nuse Jiminny\\Utils\\StringUtil;\nuse Ramsey\\Uuid\\Uuid;\nuse Sentry\\Laravel\\Facade as Sentry;\n\nclass Service extends BaseService implements\n SalesforceInterface,\n SalesforceBatchSyncInterface,\n SyncCrmEntitiesInterface,\n SyncCrmProfileRecordTypesInterface,\n ImportsBusinessProcessesInterface,\n RemoteEntityManipulationInterface,\n FetchRelatedActivityInterface,\n SendSummaryToCrmInterface,\n MatchDomainByEmailInterface,\n SearchTaskInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n SupportsObjectTypeParseInterface,\n RemoteNoteEntityManipulationInterface,\n VerifyTaskExistsInterface\n{\n use ResolveCompanyNameByEmailTrait;\n use SyncFieldsTrait;\n use DeleteObjectsTrait;\n use RecordManipulationsTrait;\n use ServiceTraits\\BatchSyncTrait;\n use FollowupActivityTrait;\n use LogActivityTrait;\n\n /**\n * Note Body Limit for the Old Note-Taking Tool\n *\n * @var int\n */\n private const int CLASSIC_NOTE_MAX_LENGTH = 32000;\n\n /**\n * Note Content Limit for the New Notes\n *\n * @var int\n */\n private const int ENHANCED_NOTE_MAX_LENGTH = 50000000;\n\n private const string INSTALLED_PACKAGE_ID = '033Tw0000007bKbIAI';\n\n private const int CACHE_TTL = 600;\n\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day - 86400\n\n /**\n * @var Client\n */\n protected $client;\n\n protected PayloadBuilder $payloadBuilder;\n protected QueryHandler $queryHandler;\n\n private OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n\n public function __construct(\n Client $client,\n PayloadBuilder $payloadBuilder,\n protected Dispatcher $eventDispatcher,\n private readonly CountriesMap $countriesMap,\n private readonly ProspectPhotoPathService $prospectPhotoPathService,\n ) {\n parent::__construct();\n\n $this->client = $client;\n $this->payloadBuilder = $payloadBuilder;\n $this->queryHandler = app(QueryHandler::class, [\n 'client' => $this->client,\n 'logger' => $this->logger,\n ]);\n $this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n }\n\n public function getDisplayName(): string\n {\n return 'Salesforce';\n }\n\n public function getJobDelay(): int\n {\n return 1;\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n return $user->getSocialAccount(SocialAccount::PROVIDER_SALESFORCE);\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($activity, $crmProviderId) {\n $playbook = $this->getPlaybookFromActivity($activity);\n\n if ($playbook === null) {\n $this->logger->warning('[Salesforce] Cannot verify task - no playbook found', [\n 'activity' => $activity->getId(),\n 'crm_provider_id' => $crmProviderId,\n ]);\n\n return false;\n }\n\n $objectType = $playbook->getActivityType() === Playbook::ACTIVITY_TYPE_EVENT ? 'Event' : 'Task';\n\n try {\n $record = $this->getRecord($objectType, $crmProviderId, ['Id', 'IsDeleted']);\n\n return ! empty($record) && ($record['IsDeleted'] ?? false) === false;\n } catch (HttpNotFoundException|HttpBadRequestException) {\n $this->logger->info('[Salesforce] Activity record not found during verification', [\n 'activity' => $activity->getId(),\n 'object_type' => $objectType,\n 'crm_provider_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n });\n }\n\n public function query(string $queryToRun, array $parameters = []): QueryIterator\n {\n // Due to poorly designed external calls, this method cannot be entirely removed\n return $this->queryHandler->query($queryToRun, $parameters);\n }\n\n /*=========== Organization Information ===============*/\n\n /**\n * Get a list of all the API Versions for the instance.\n *\n * @throws CrmException\n *\n * @return mixed\n *\n */\n public function getApiVersions()\n {\n $url = $this->config->crm_base_url . '/services/data';\n\n $response = $this->client->get($url);\n\n return json_decode($response->getBody(), true);\n }\n\n /**\n * Gets the valid recordTypes for a given Salesforce Object via the describe API.\n */\n private function getRecordTypes(string $crmObject): array\n {\n $url = $this->client->getObjectsUrl() . $crmObject . '/describe';\n\n $response = $this->client->get($url);\n $jsonResponse = json_decode($response->getBody(), true);\n\n $fields = [];\n foreach ($jsonResponse['recordTypeInfos'] as $row) {\n $fields[] = ['recordTypeId' => $row['recordTypeId'], 'default' => $row['defaultRecordTypeMapping']];\n }\n\n return $fields;\n }\n\n /**\n * Convert raw field data into a format compatible with CRM APIs.\n */\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return ValueNormalizer::normalize($fieldType, $fieldValue, $internal);\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n $defaultFields = ($activityType === Playbook::ACTIVITY_TYPE_TASK)\n ? FieldDefinitions::defaultTaskFields()\n : FieldDefinitions::defaultEventFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n // Setup the activity field as the default Type.\n /** @var Field $activityField */\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'Type',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK, Playbook::ACTIVITY_TYPE_EVENT];\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n\n $fieldFilter = ($activityType === Playbook::ACTIVITY_TYPE_TASK)\n ? FieldDefinitions::taskFollowupFieldsFilter()\n : FieldDefinitions::eventFollowupFieldsFilter();\n\n foreach ($fieldFilter as $eachFilter) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $eachFilter);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n /**\n * This one is now called only when ImportActivityTypes is triggered or SyncFieldMetadata executed manually\n * Regular sync now uses SharedSyncFieldsTrait -> syncSingleObjectType\n * Needs to be replaced later on\n */\n public function syncField(Field $field): void\n {\n try {\n if (FieldHelper::isCustomField($field)) {\n $query = '\n SELECT\n Id, Metadata, TableEnumOrId\n FROM\n CustomField\n WHERE\n DeveloperName = :fieldName\n AND\n TableEnumOrId = :fieldType\n AND\n NamespacePrefix = :namespacePrefix';\n\n // We need to constrain the field lookup to the object, in case it's used in multiple places.\n $objectType = \\in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true)\n ? 'activity'\n : $field->object_type;\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'fieldName' => substr($field->crm_provider_id, 0, -\\strlen('__c')),\n 'fieldType' => ucfirst($objectType),\n\n // This is used to ensure we only consider the field within the org, not installed packages.\n 'namespacePrefix' => 'null',\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n // Sync field metadata.\n $metadata = $sfField['Metadata'];\n\n $field->description = mb_strimwidth($metadata['description'] ?? '', 0, 191);\n $field->label = mb_strimwidth($metadata['label'] ?? '', 0, Field::LABEL_MAX_LENGTH);\n $field->type = $this->convertFieldType($metadata['type'], $field->getEntityName());\n $field->is_mandatory = ($metadata['required'] === true);\n $field->length = $metadata['length'];\n $field->default_value = mb_strimwidth(trim($metadata['defaultValue'] ?? '', '\"'), 0, 191);\n $field->save();\n } else {\n $query = '\n SELECT\n Id, DataType, DeveloperName, Label, Length, Description\n FROM\n FieldDefinition\n WHERE\n DurableId = :entityName';\n\n $entityName = $field->getEntityName();\n $sfFields = $this->queryHandler->metadata($query, [\n 'entityName' => $entityName,\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n $convertedType = $this->convertFieldType($sfField['DataType'], $entityName);\n $label = mb_strimwidth($sfField['Label'], 0, Field::LABEL_MAX_LENGTH);\n\n if ($field->isBusinessType()) {\n $label = 'Opportunity Type';\n }\n\n $field->description = mb_strimwidth($sfField['Description'], 0, Field::DESCRIPTION_MAX_LENGTH);\n $field->label = $label;\n $field->type = $convertedType;\n $field->length = $sfField['Length'];\n $field->save();\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n private function convertFieldType(string $from, ?string $entityName = null): string\n {\n $converter = new FieldTypeConverter();\n\n return $converter->convert($from, $entityName);\n }\n\n /**\n * @inheritdoc\n */\n public function importPicklistValues(Field $field): array\n {\n $values = [];\n $fieldValues = [];\n\n try {\n if (FieldHelper::isCustomField($field)) {\n $query = '\n SELECT\n Id, Metadata, TableEnumOrId\n FROM\n CustomField\n WHERE\n DeveloperName = :fieldName\n AND\n TableEnumOrId = :fieldType\n AND\n NamespacePrefix = :namespacePrefix';\n\n // We need to constrain the field lookup to the object, in case it's used in multiple places.\n $objectType = \\in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true) ?\n 'activity' : $field->object_type;\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'fieldName' => substr($field->crm_provider_id, 0, -\\strlen('__c')),\n 'fieldType' => ucfirst($objectType),\n // This is used to ensure we only consider the field within the org, not installed packages.\n 'namespacePrefix' => 'null',\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n $valueSet = $sfField['Metadata']['valueSet'];\n\n if ($valueSet['valueSetName'] === null) {\n // Local picklist values can be obtained easily.\n $picklistValues = $valueSet['valueSetDefinition']['value'];\n } else {\n // But for some fields, we just get the Global Value Picklist pointer so need to do more work.\n $picklistValues = $this->importGlobalValuePicklistValues($valueSet['valueSetName']);\n }\n\n // Import all active values.\n foreach ($picklistValues as $i => $sfFieldValue) {\n // Setup default value.\n if ($sfFieldValue['default']) {\n $field->update(['default_value' => $sfFieldValue['valueName']]);\n }\n\n // This comes through as null if active (lol).\n if ($sfFieldValue['isActive'] !== false) {\n $values[] = [\n 'value' => $sfFieldValue['valueName'],\n 'label' => $sfFieldValue['valueName'],\n 'sequence' => $i,\n 'is_default' => $sfFieldValue['default'],\n ];\n }\n }\n } else {\n $objectFields = $this->getObjectFields($field->object_type);\n $fieldId = $field->crm_provider_id;\n\n // Only work with our field of interest.\n $objectField = array_filter($objectFields, function ($item) use ($fieldId) {\n return $item['name'] === $fieldId;\n });\n\n $objectField = array_shift($objectField);\n if (empty($objectField['picklistValues']) === false) {\n foreach ($objectField['picklistValues'] as $i => $sfFieldValue) {\n // Skip inactive values.\n if ($sfFieldValue['active'] === false) {\n continue;\n }\n\n // Setup default value.\n if ($sfFieldValue['defaultValue']) {\n $field->update(['default_value' => $sfFieldValue['value']]);\n }\n\n $values[] = [\n 'value' => $sfFieldValue['value'],\n 'label' => $sfFieldValue['label'],\n 'sequence' => $i,\n 'is_default' => $sfFieldValue['defaultValue'],\n ];\n }\n }\n }\n\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n $value['value'] = substr($value['value'] ?? '', 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n if (($key = array_search($value['value'], $fieldsToPurge, true)) !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n // Get IDs of the values to be deleted\n $valuesToDelete = $field->values()->whereIn('value', $fieldsToPurge);\n $valuesToDeleteIds = $valuesToDelete->pluck('id');\n if (! $valuesToDeleteIds->isEmpty()) {\n $recordTypeFieldValuesRepository = app(RecordTypeFieldValuesRepository::class);\n $recordTypeFieldValuesRepository->deleteForCrmFieldValueIds($valuesToDeleteIds->toArray());\n\n // Now safely delete from crm_field_values\n $valuesToDelete->delete();\n }\n\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n return $fieldValues;\n }\n\n /**\n * Gets values from Global Value Picklists.\n */\n private function importGlobalValuePicklistValues(string $picklistName): array\n {\n $query = '\n SELECT\n Metadata\n FROM\n GlobalValueSet\n WHERE\n DeveloperName = :picklistName\n LIMIT 1';\n\n try {\n $sfValues = $this->queryHandler->metadata($query, [\n 'picklistName' => $picklistName,\n ]);\n\n // There is always 1 result at this point.\n $sfValue = $sfValues->current();\n\n return $sfValue['Metadata']['customValue'];\n } catch (NoResultsException $noResultsException) {\n // Nothing returned.\n\n return [];\n }\n }\n\n /**\n * @inheritdoc\n */\n public function syncProfileRecordTypes(): void\n {\n $objectTypes = [\n 'lead',\n 'account',\n 'contact',\n 'opportunity',\n 'task',\n 'event',\n ];\n\n foreach ($objectTypes as $objectType) {\n try {\n $crmRecordTypes = $this->getRecordTypes(ucfirst($objectType));\n\n foreach ($crmRecordTypes as $crmRecordType) {\n // If the record type is default and not the Master type, set this.\n if ($crmRecordType['default'] && $crmRecordType['recordTypeId'] !== '012000000000000AAA') {\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $crmRecordType['recordTypeId'])\n ->first();\n\n if ($recordType) {\n $this->profile->{$objectType . '_record_type_id'} = $recordType->id;\n }\n }\n }\n } catch (HttpNotFoundException $exception) {\n Log::error('No access to ' . $objectType . ' object, skipping...');\n\n // XXX: should we log this fact somewhere?\n continue;\n }\n }\n\n if ($this->profile->isDirty()) {\n $this->profile->save();\n }\n }\n\n /**\n * Gets business processes.\n */\n public function importBusinessProcesses(): void\n {\n $query = '\n SELECT\n Id, IsActive, Name, TableEnumOrId\n FROM\n BusinessProcess\n WHERE\n TableEnumOrId IN (\\'Lead\\',\\'Opportunity\\')';\n\n try {\n $sfProcesses = $this->queryHandler->query($query);\n\n // Upsert all processes for the team.\n foreach ($sfProcesses as $sfProcess) {\n /** @var BusinessProcess $businessProcess */\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $sfProcess['Id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => $sfProcess['Name'],\n 'type' => $sfProcess['TableEnumOrId'] === 'Lead' ? 'lead' : 'opportunity',\n 'is_selectable' => $sfProcess['IsActive'],\n ]);\n\n $this->importBusinessProcessStages($businessProcess);\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n /**\n * Gets business process stages.\n */\n private function importBusinessProcessStages(BusinessProcess $businessProcess): void\n {\n $query = '\n SELECT\n Metadata\n FROM\n BusinessProcess\n WHERE\n Id = :processId';\n\n try {\n $stages = [];\n $sfProcessStages = $this->queryHandler->metadata($query, [\n 'processId' => $businessProcess->crm_provider_id,\n ]);\n\n // There is always 1 result at this point.\n $sfProcessStage = $sfProcessStages->current();\n\n // Upsert all processes for the team.\n foreach ($sfProcessStage['Metadata']['values'] as $sfProcessStage) {\n $sanitizedName = urldecode($sfProcessStage['valueName']); // Must decode: \"%2C\" becomes \",\" etc.\n\n $stage = $businessProcess->crm->stages()\n // This MUST match on label because this API doesn't use API Name.\n ->where('label', $sanitizedName)\n ->where('type', $businessProcess->type)\n ->where('is_selectable', 1)\n ->first();\n\n if ($stage) {\n $stages[] = $stage->id;\n }\n }\n\n $businessProcess->stages()->sync($stages);\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n /**\n * Gets record types.\n */\n public function importRecordTypes(): void\n {\n $query = '\n SELECT\n Id, IsActive, Name, BusinessProcessId, SobjectType\n FROM\n RecordType';\n\n try {\n $sfRecordTypes = $this->queryHandler->query($query);\n\n // Upsert all record types for the process.\n foreach ($sfRecordTypes as $sfRecordType) {\n $businessProcess = null;\n if ($sfRecordType['BusinessProcessId']) {\n $businessProcess = $this->config->businessProcesses()\n ->where('crm_provider_id', $sfRecordType['BusinessProcessId'])\n ->first();\n }\n\n /** @var RecordType $recordType */\n $recordType = $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $sfRecordType['Id'],\n ], [\n 'team_id' => $this->team->id,\n 'type' => mb_strtolower($sfRecordType['SobjectType']),\n 'name' => $sfRecordType['Name'],\n 'is_selectable' => $sfRecordType['IsActive'],\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n $this->importRecordTypeFieldValues($recordType);\n }\n } catch (NoResultsException $noResultsException) {\n // Do nothing.\n }\n }\n\n /**\n * Import record type - field value mappings. This only works for standard fields.\n */\n private function importRecordTypeFieldValues(RecordType $recordType): void\n {\n try {\n $query = '\n SELECT\n Metadata\n FROM\n RecordType\n WHERE\n Id = :recordTypeId';\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'recordTypeId' => $recordType->crm_provider_id,\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n // Sync field metadata.\n $picklists = $sfField['Metadata']['picklistValues'];\n\n foreach ($picklists as $picklist) {\n $field = $this->config->fields()->where([\n 'type' => Field::TYPE_PICKLIST,\n 'object_type' => $recordType->type,\n 'crm_provider_id' => $picklist['picklist'],\n ])->first();\n\n if ($field) {\n $fieldValues = [];\n\n foreach ($picklist['values'] as $value) {\n // Must decode: \"%2C\" becomes \",\" etc.\n $fieldValue = $field->values()\n ->where('value', urldecode($value['valueName']))\n ->first();\n\n if ($fieldValue) {\n $fieldValues[] = $fieldValue->id;\n }\n }\n\n $recordType->fieldValues()->sync($fieldValues);\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n /**\n * @inheritdoc\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $params = [];\n $missingStage = null;\n if ($types === null) {\n $types = [Stage::TYPE_LEAD, Stage::TYPE_OPPORTUNITY];\n }\n\n foreach ($types as $type) {\n if ($type === Stage::TYPE_LEAD) {\n $query = '\n SELECT\n Id, ApiName, MasterLabel, SortOrder\n FROM\n LeadStatus';\n } else {\n $query = '\n SELECT\n Id, ApiName, MasterLabel, IsActive, SortOrder, DefaultProbability\n FROM\n OpportunityStage';\n }\n\n if ($missingStageName) {\n $escapedStageName = ValueNormalizer::replaceQueryWithStringLiterals($missingStageName);\n\n $query .= ' WHERE ApiName = :stageName';\n\n $params = [\n 'stageName' => $escapedStageName,\n ];\n }\n\n try {\n $sfStages = $this->queryHandler->query($query, $params);\n } catch (NoResultsException $exception) {\n $sfStages = [];\n }\n\n $missingStage = null;\n\n // Upsert all stages for the team.\n foreach ($sfStages as $sfStage) {\n $selectable = true;\n if (array_key_exists('IsActive', $sfStage)) {\n $selectable = $sfStage['IsActive'];\n }\n\n $this->restoreAnyTrashedEntity($this->config->stages(), $sfStage['Id']);\n\n $stage = $this->config->stages()->updateOrCreate([\n 'crm_provider_id' => $sfStage['Id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($sfStage['ApiName'], 0, 50),\n 'label' => mb_strimwidth($sfStage['MasterLabel'], 0, 191),\n 'type' => $type,\n 'sequence' => $sfStage['SortOrder'] ?? 0,\n 'is_selectable' => $selectable,\n 'probability' => $sfStage['DefaultProbability'] ?? null,\n ]);\n\n if ($missingStageName && $missingStageName === $sfStage['ApiName']) {\n $missingStage = $stage;\n }\n }\n\n if ($missingStageName && $missingStage === null) {\n // If they requested a stage that still doesn't exist, it must be inactive so lazy create it.\n $missingStage = $this->config->stages()->create([\n 'crm_provider_id' => Uuid::uuid4(),\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($missingStageName, 0, 50),\n 'label' => mb_strimwidth($missingStageName, 0, 191),\n 'type' => $type,\n 'sequence' => 0,\n 'is_selectable' => 0,\n ]);\n }\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int\n {\n $syncCount = 0;\n $fields = $this->getAllFieldsAsArray('lead');\n if (\\in_array('Id', $fields, true) === false) {\n return $syncCount;\n }\n\n $query = '\n SELECT ' . rtrim(implode(',', $fields), ',') . '\n FROM Lead\n WHERE LastModifiedDate > :since\n ORDER BY LastModifiedDate ASC';\n\n try {\n $sfLeads = $this->queryHandler->query($query, [\n 'since' => $since->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($sfLeads as $sfLead) {\n // Only sync if previously imported.\n if ($this->hasLead($sfLead['Id'])) {\n $this->importLead($sfLead);\n $syncCount++;\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::LEAD);\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLead(string $crmId): ?Lead\n {\n $fields = $this->getAllFieldsAsArray('lead');\n\n $sfLead = $this->getRecord('Lead', $crmId, $fields);\n\n return $this->importLead($sfLead);\n }\n\n private function importLead($crmData): ?Lead\n {\n /** @var ?Stage $stage */\n $stage = null;\n if (isset($crmData['Status'])) {\n // Get the current stage.\n $stage = $this->config\n ->stages()\n ->where('name', $crmData['Status'])\n ->where('type', Stage::TYPE_LEAD)\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_LEAD], $crmData['Status']);\n }\n }\n\n // If we have no way of importing this, just return null :(\n if ($stage === null) {\n return null;\n }\n\n $countryCode = $crmData['CountryCode'] ?? null;\n\n // Salesforce allows custom \"countries\" to be created. Disregard these.\n if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {\n $countryCode = null;\n }\n\n // If we have no country code, try to parse it from the country name.\n if ($countryCode === null && empty($crmData['Country']) !== false) {\n $countryCode = $this->convertCountryNameToCode($crmData['Country']);\n }\n\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($crmData['Phone'] ?? '', 0, 25);\n $parsedNumber = parsePhoneNumber($countryCode, $number);\n\n $mobilePhone = null;\n if (empty($crmData['MobilePhone']) === false) {\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($crmData['MobilePhone'], 0, 25);\n $mobilePhone = phone_e164($countryCode, $number);\n }\n\n $convertedDate = null;\n $convertedAccount = null;\n $convertedOpportunity = null;\n $convertedContact = null;\n\n if ($crmData['IsConverted'] == 'true') {\n $convertedDate = $crmData['ConvertedDate'];\n\n if (empty($crmData['ConvertedAccountId']) === false) {\n $convertedAccount = $this->config\n ->accounts()\n ->where('crm_provider_id', $crmData['ConvertedAccountId'])\n ->first();\n\n if ($convertedAccount === null) {\n try {\n $convertedAccount = $this->syncAccount($crmData['ConvertedAccountId']);\n } catch (HttpNotFoundException $exception) {\n // Probably the user has no permissions to access the converted data.\n }\n }\n }\n\n if (empty($crmData['ConvertedOpportunityId']) === false) {\n $convertedOpportunity = $this->config\n ->opportunities()\n ->where('crm_provider_id', $crmData['ConvertedOpportunityId'])\n ->first();\n\n if ($convertedOpportunity === null) {\n try {\n $convertedOpportunity = $this->syncOpportunity($crmData['ConvertedOpportunityId']);\n } catch (HttpNotFoundException $exception) {\n // Probably the user has no permissions to access the converted data.\n }\n }\n }\n\n if (empty($crmData['ConvertedContactId']) === false) {\n $convertedContact = $this->team\n ->crm\n ->contacts()\n ->where('crm_provider_id', $crmData['ConvertedContactId'])\n ->first();\n\n if ($convertedContact === null) {\n try {\n $convertedContact = $this->syncContact($crmData['ConvertedContactId']);\n } catch (HttpNotFoundException $exception) {\n // Probably the user has no permissions to access the converted data.\n }\n }\n }\n }\n\n if (empty($crmData['Company'])) {\n $company = 'Unknown';\n } else {\n $company = mb_strimwidth($crmData['Company'], 0, 191);\n }\n\n $domain = null;\n if (empty($crmData['Website']) === false) {\n $domain = mb_strimwidth($crmData['Website'], 0, 191);\n $domain = StringUtil::resolveDomain($domain);\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $data = [\n 'team_id' => $this->team->id,\n 'user_id' => $profile?->user_id,\n 'owner_id' => $crmData['OwnerId'] ?? '',\n 'company' => $company,\n 'domain' => $domain,\n 'name' => $crmData['Name'] ? mb_strimwidth($crmData['Name'], 0, 191) : '',\n 'title' => $crmData['Title'] ? mb_strimwidth($crmData['Title'], 0, 128) : null,\n 'email' => $crmData['Email'] ? mb_strimwidth($crmData['Email'], 0, 80) : null,\n 'phone' => $parsedNumber['phone'],\n 'ext' => $parsedNumber['ext'] ?? null,\n 'mobile_phone' => $mobilePhone,\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['Id'],\n modelType: Lead::class,\n fileName: $crmData['Id'],\n avatarText: $crmData['Name']\n ),\n 'stage_id' => $stage->id,\n 'record_type_id' => null,\n 'converted_at' => $convertedDate,\n 'converted_account_id' => $convertedAccount->id ?? null,\n 'converted_opportunity_id' => $convertedOpportunity->id ?? null,\n 'converted_contact_id' => $convertedContact->id ?? null,\n 'country_code' => $countryCode,\n 'remotely_created_at' => $createdDate,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->leads(), $crmData['Id']);\n\n /** @var Lead $lead */\n $lead = $this->config->leads()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n if ($lead->wasChanged('converted_at') && $lead->getConvertedAt() !== null) {\n $this->eventDispatcher->dispatch(new LeadConverted($lead));\n }\n\n $this->handleObjectDeletion($lead, $crmData);\n\n return $lead;\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccounts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n $fields = $this->getAllFieldsAsArray('account');\n\n if (\\in_array('Id', $fields, true) === false) {\n return $syncCount;\n }\n\n $query = '\n SELECT ' . rtrim(implode(',', $fields), ',') . '\n FROM Account\n WHERE LastModifiedDate > :since\n ORDER BY LastModifiedDate ASC';\n\n try {\n $sfAccounts = $this->queryHandler->query($query, [\n 'since' => $since->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($sfAccounts as $sfAccount) {\n // Only sync if previously imported.\n if ($this->hasAccount($sfAccount['Id'])) {\n $this->importAccount($sfAccount);\n $syncCount++;\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::ACCOUNT);\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccount(string $crmId): ?Account\n {\n $fields = $this->getAllFieldsAsArray('account');\n if (! in_array('Id', $fields, true)) {\n $this->logger->info('[Salesforce] Sync account cancelled. Fields are not available.', [\n 'crmId' => $crmId,\n 'userId' => $this->profile->getUserId(),\n ]);\n\n return null;\n }\n\n $sfAccount = $this->getRecord('Account', $crmId, $fields);\n\n return $this->importAccount($sfAccount);\n }\n\n private function importAccount($crmData): Account\n {\n $countryCode = $crmData['BillingCountryCode'] ?? $crmData['ShippingCountryCode'] ?? null;\n\n // Salesforce allows custom \"countries\" to be created. Disregard these.\n if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {\n $countryCode = null;\n }\n\n // If we have no country code, try to parse it from the country names.\n if ($countryCode === null && empty($crmData['BillingCountry']) === false) {\n $countryCode = $this->convertCountryNameToCode($crmData['BillingCountry']);\n }\n\n if ($countryCode === null && empty($crmData['ShippingCountry']) === false) {\n $countryCode = $this->convertCountryNameToCode($crmData['ShippingCountry']);\n }\n\n if (empty($crmData['Phone']) === false) {\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($crmData['Phone'], 0, 25);\n $parsedNumber = parsePhoneNumber($countryCode, $number);\n } else {\n $parsedNumber = [];\n }\n\n $industry = null;\n if (empty($crmData['Industry']) === false) {\n $industry = mb_strimwidth($crmData['Industry'], 0, 40);\n }\n\n $domain = null;\n if (empty($crmData['Website']) === false) {\n $domain = mb_strimwidth($crmData['Website'], 0, 191);\n $domain = StringUtil::resolveDomain($domain);\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $data = [\n 'team_id' => $this->team->id,\n 'user_id' => $profile?->user_id,\n 'owner_id' => $crmData['OwnerId'],\n 'name' => mb_strimwidth($crmData['Name'], 0, 191),\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['Id'],\n modelType: Account::class,\n fileName: $crmData['Id'],\n avatarText: $crmData['Name']\n ),\n 'industry' => $industry,\n 'domain' => $domain,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'ext' => $parsedNumber['ext'] ?? null,\n 'country_code' => $countryCode,\n 'remotely_created_at' => $createdDate,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->accounts(), $crmData['Id']);\n\n /** @var Account $account */\n $account = $this->config->accounts()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n $this->handleObjectDeletion($account, $crmData);\n\n return $account;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOpportunities(array $parameters, ?string $strategy = null): int\n {\n $strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);\n\n $syncCount = 0;\n $logParams = $parameters;\n $parameters['profile'] = $this->profile;\n $logParams['user'] = $this->profile->getUserId();\n\n if (count($strategies) > 1) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Multiple sync strategies used', [\n 'teamId' => $this->team->getUuid(),\n 'params' => $logParams,\n 'strategies_count' => count($strategies),\n ]);\n }\n\n foreach ($strategies as $syncStrategy) {\n $name = $syncStrategy->getStrategyName();\n\n try {\n $sfOpportunities = $syncStrategy->fetchOpportunities($parameters);\n $totalRecords = $sfOpportunities->count();\n\n foreach ($sfOpportunities as $sfOpportunity) {\n $this->importOpportunity($sfOpportunity);\n $syncCount++;\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n $this->logger->warning('[' . $this->getDisplayName() . '] No opportunities found', [\n 'teamId' => $this->team->getUuid(),\n 'name' => $name,\n 'params' => $logParams,\n 'reason' => $noResultsException->getMessage(),\n ]);\n } catch (CrmException $crmException) {\n // Nothing to sync.\n $this->logger->warning('[' . $this->getDisplayName() . '] Opportunity sync failed', [\n 'teamId' => $this->team->getUuid(),\n 'name' => $name,\n 'params' => $logParams,\n 'reason' => $crmException->getMessage(),\n ]);\n }\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::OPPORTUNITY, ['params' => $logParams]);\n\n // debug to see how if count of opportunities reaches 1000\n if ($syncCount >= 1000) {\n $this->logger->info(\n '[' . $this->getDisplayName() . '] Sync Opportunities - count warning',\n [\n 'team_id' => $this->team->getId(),\n 'params' => $logParams,\n 'count' => $syncCount,\n 'strategies_count' => count($strategies),\n 'total_records' => $totalRecords ?? null,\n ]\n );\n }\n\n return $syncCount;\n }\n\n\n /**\n * @inheritdoc\n */\n public function syncOpportunity(string $crmId): ?Opportunity\n {\n $strategy = $this->opportunitySyncStrategyResolver->resolve(\n $this->config,\n OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY\n );\n\n $parameters = [\n 'profile' => $this->profile,\n 'crm_id' => $crmId,\n ];\n\n try {\n $sfOpportunity = $strategy->fetchOpportunities($parameters);\n } catch (HttpNotFoundException $e) {\n $this->logger->info('[' . $this->getDisplayName() . '] Opportunity not found', [\n 'teamId' => $this->team->id_string,\n 'crmId' => $crmId,\n ]);\n\n return null;\n } catch (CrmException $crmException) {\n $this->logger->info('[' . $this->getDisplayName() . '] Opportunity sync failed', [\n 'teamId' => $this->team->id_string,\n 'crmId' => $crmId,\n 'exception' => $crmException->getMessage(),\n ]);\n\n return null;\n }\n\n if ($sfOpportunity instanceof ArrayIterator) {\n return $this->importOpportunity($sfOpportunity->getItems());\n }\n\n return $this->importOpportunity($sfOpportunity);\n }\n\n /**\n * @throws HttpNotFoundException\n */\n private function importOpportunity($crmData): ?Opportunity\n {\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $account = null;\n if (empty($crmData['AccountId']) === false) {\n /** @var ?Account $account */\n $account = $this->config->accounts()\n ->where('crm_provider_id', (string) $crmData['AccountId'])\n ->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmData['AccountId']);\n }\n }\n\n $userId = $profile?->getUserId() ?? $account?->getUserId();\n if ($userId === null) {\n $this->logger->error('[Salesforce] | Skip import, no user_id found', [\n 'id' => $crmData['Id'],\n ]);\n\n return null;\n }\n\n /** @var ?Stage $stage */\n $stage = null;\n if (isset($crmData['StageName'])) {\n $stage = $this->config\n ->stages()\n ->where('name', $crmData['StageName'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->orderBy('is_selectable', 'DESC')\n ->orderBy('id')\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $crmData['StageName']);\n }\n }\n\n $recordType = null;\n if (empty($crmData['RecordTypeId']) === false) {\n /** @var ?RecordType $recordType */\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $crmData['RecordTypeId'])\n ->first();\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $closeDate = null;\n if (empty($crmData['CloseDate']) === false) {\n $closeDate = Carbon::parse($crmData['CloseDate'])->format('Y-m-d');\n }\n\n $valueFieldName = 'Amount';\n if ($this->config->opportunity_value_field_id) {\n $valueFieldName = $this->config->opportunityValueField->crm_provider_id;\n }\n\n $data = [\n 'team_id' => $this->team->id,\n 'account_id' => $account->id ?? null,\n 'user_id' => $userId,\n 'owner_id' => $crmData['OwnerId'] ?? null,\n 'name' => mb_strimwidth($crmData['Name'] ?? '', 0, 128),\n 'value' => $crmData[$valueFieldName],\n 'currency_code' => CurrencyFormatter::formatCode($crmData['CurrencyIsoCode'] ?? null),\n 'close_date' => $closeDate,\n 'is_closed' => $crmData['IsClosed'],\n 'is_won' => $crmData['IsWon'],\n 'stage_id' => $stage?->id ?? null,\n 'record_type_id' => $recordType->id ?? null,\n 'remotely_created_at' => $createdDate,\n 'probability' => $crmData['Probability'] ?? null,\n 'forecast_category' => $crmData['ForecastCategoryName'] ?? null,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->opportunities(), $crmData['Id']);\n\n // Do not allow locked DB tables & other errors\n // to interrupt the process of reverting the trashed opportunities\n try {\n /** @var Opportunity $opportunity */\n $opportunity = $this->config->opportunities()\n ->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n // import external fields into crm_field_data if present\n $crmFields = $this->getOpportunitySyncableFields();\n\n $this->importOpportunityCrmFieldData($crmData, $crmFields, $opportunity->id);\n\n $this->handleObjectDeletion($opportunity, $crmData);\n\n if ($opportunity->wasRecentlyCreated) {\n MatchActivitiesToNewOpportunity::dispatch($opportunity->getId());\n }\n\n return $opportunity;\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n $this->logger->error('[Salesforce] importOpportunity failure.', [\n 'crm_provider_id' => $crmData['Id'],\n 'team_id' => $this->team->id,\n 'exception' => $exception->getMessage(),\n ]);\n\n $this->handleEntityDeletionByProviderId($this->config->opportunities(), $crmData);\n }\n\n return null;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContacts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n $fields = $this->getAllFieldsAsArray('contact');\n if (\\in_array('Id', $fields, true) === false) {\n return $syncCount;\n }\n\n $query = '\n SELECT ' . rtrim(implode(',', $fields), ',') . '\n FROM Contact\n WHERE LastModifiedDate > :since\n ORDER BY LastModifiedDate ASC';\n\n try {\n $sfContacts = $this->queryHandler->query($query, [\n 'since' => $since->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($sfContacts as $sfContact) {\n // Only sync if previously imported.\n if ($this->hasContact($sfContact['Id'])) {\n $this->importContact($sfContact);\n $syncCount++;\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::CONTACT);\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContact(string $crmId): ?Contact\n {\n $fields = $this->getAllFieldsAsArray('contact');\n if (! in_array('Id', $fields, true)) {\n $this->logger->info('[Salesforce] Sync contact cancelled. Fields are not available.', [\n 'crmId' => $crmId,\n 'userId' => $this->profile->getUserId(),\n ]);\n\n return null;\n }\n\n $sfContact = $this->getRecord('Contact', $crmId, $fields);\n\n return $this->importContact($sfContact);\n }\n\n private function importContact($crmData): Contact\n {\n $account = null;\n // Contacts may not have accounts...\n if (isset($crmData['AccountId'])) {\n $account = $this->config->accounts()\n ->where('crm_provider_id', (string) $crmData['AccountId'])\n ->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmData['AccountId']);\n }\n }\n\n $countryCode = $crmData['MailingCountryCode'] ?? null;\n\n // Salesforce allows custom \"countries\" to be created. Disregard these.\n if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {\n $countryCode = null;\n }\n\n // If we have no country code, try to parse it from the country name.\n if ($countryCode === null && empty($crmData['MailingCountry']) === false) {\n $countryCode = $this->convertCountryNameToCode($crmData['MailingCountry']);\n\n if ($countryCode === null && $account) {\n $countryCode = $account->country_code;\n }\n }\n\n $ext = null;\n $parsedNumber = [];\n if (empty($crmData['Phone']) === false) {\n $number = Str::limit($crmData['Phone'], 25, '');\n $parsedNumber = parsePhoneNumber($countryCode, $number);\n\n if (empty($parsedNumber['ext']) === false) {\n $ext = Str::limit($parsedNumber['ext'], 10, '');\n }\n }\n\n $mobileNumber = null;\n if (empty($crmData['MobilePhone']) === false) {\n $mobileNumber = Str::limit(phone_e164($countryCode, $crmData['MobilePhone']), 25, '');\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $data = [\n 'team_id' => $this->team->id,\n 'account_id' => $account->id ?? null,\n 'user_id' => $profile?->user_id,\n 'owner_id' => $crmData['OwnerId'] ?? null,\n 'name' => ($crmData['Name'] ?? null) !== null ? mb_strimwidth($crmData['Name'], 0, 100) : '',\n 'title' => ($crmData['Title'] ?? null) !== null ? mb_strimwidth($crmData['Title'], 0, 128) : null,\n 'email' => ($crmData['Email'] ?? null) !== null ? mb_strimwidth($crmData['Email'], 0, 191) : null,\n 'country_code' => $countryCode,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'ext' => $ext,\n 'mobile_phone' => $mobileNumber,\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['Id'],\n modelType: Contact::class,\n fileName: $crmData['Id'],\n avatarText: $crmData['Name']\n ),\n 'remotely_created_at' => $createdDate,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->contacts(), $crmData['Id']);\n\n /** @var Contact $contact */\n $contact = $this->config->contacts()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n $this->handleObjectDeletion($contact, $crmData);\n\n return $contact;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n $fields = [\n 'InstanceName',\n 'OrganizationType',\n 'IsSandbox',\n ];\n\n $orgValues = $this->getRecord('Organization', $this->config->crm_provider_id, $fields);\n\n $edition = null;\n switch ($orgValues['OrganizationType']) {\n case 'Developer Edition':\n $edition = Configuration::EDITION_DEVELOPER;\n\n break;\n\n case 'Professional Edition':\n $edition = Configuration::EDITION_PROFESSIONAL;\n\n break;\n\n case 'Enterprise Edition':\n $edition = Configuration::EDITION_ENTERPRISE;\n\n break;\n }\n\n $this->config->edition = $edition;\n $this->config->instance = $orgValues['InstanceName'];\n\n // XXX: How can this state be possible?\n if ($this->config->version === null) {\n $this->config->version = Client::MIN_API_VERSION;\n }\n\n $installedVersion = $this->getInstalledAppVersion();\n if ($installedVersion !== null) {\n $installedVersion = (string) $this->getInstalledAppVersion();\n }\n\n $this->config->installed_app_version = $installedVersion;\n\n $this->config->save();\n }\n\n public function getInstalledAppVersion(): ?string\n {\n try {\n $query = '\n SELECT\n SubscriberPackageVersion.MajorVersion,\n SubscriberPackageVersion.MinorVersion,\n SubscriberPackageVersion.PatchVersion,\n SubscriberPackageVersion.BuildNumber\n FROM\n InstalledSubscriberPackage\n WHERE\n SubscriberPackageId = :packageId\n ';\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'packageId' => self::INSTALLED_PACKAGE_ID,\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n // Grab version number.\n $version = $sfField['SubscriberPackageVersion']['MajorVersion'] .\n $sfField['SubscriberPackageVersion']['MinorVersion'] .\n $sfField['SubscriberPackageVersion']['PatchVersion'] .\n $sfField['SubscriberPackageVersion']['BuildNumber'];\n } catch (\\Exception) {\n $version = null;\n }\n\n return $version;\n }\n\n /**\n * Store transcripts as note.\n *\n * @throws \\Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // For SF we also check if Log Notes is enabled.\n if ($this->profile->log_notes === Profile::LOG_NOTE_NONE) {\n return;\n }\n\n if ($activity->opportunity_id && $activity->prospect === null) {\n return;\n }\n\n try {\n $transcriptionData = $this->generateTranscription($activity);\n\n $noteMaxLength = $this->profile->log_notes === Profile::LOG_NOTE_ENHANCED\n ? self::ENHANCED_NOTE_MAX_LENGTH\n : self::CLASSIC_NOTE_MAX_LENGTH;\n\n $title = 'Transcript for ';\n $title .= $activity->title ?? $activity->activity_title;\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $body = mb_strimwidth($transcriptionData, 0, $noteMaxLength);\n\n if ($activity->opportunity_id) {\n $objectId = $activity->opportunity->crm_provider_id;\n } else {\n $objectId = $activity->prospect->crm_provider_id;\n }\n\n $noteId = $this->saveNote($title, $body, $objectId);\n\n // Store crm logged id in transcription.\n $transcription = $activity->getTranscription();\n $transcription->crm_activity_id = $noteId;\n $transcription->save();\n } catch (\\Exception $e) {\n \\Sentry::captureException($e);\n }\n }\n\n public function saveNote(string $title, string $body, string $objectId, ?NoteObject $noteObject = null): ?string\n {\n $noteId = null;\n\n try {\n if ($this->profile->log_notes === Profile::LOG_NOTE_ENHANCED) {\n $noteId = $this->buildEnhancedNote($title, $body, $objectId);\n } else {\n $noteId = $this->buildClassicNote($title, $body, $objectId);\n }\n } catch (HttpNotFoundException $exception) {\n // The profile not having access to create Enhanced Notes. Set their preference to Classic.\n if ($this->profile->log_notes === Profile::LOG_NOTE_ENHANCED) {\n $this->profile->update([\n 'log_notes' => Profile::LOG_NOTE_CLASSIC,\n ]);\n }\n }\n\n return $noteId;\n }\n\n /**\n * This is using the \"Enhanced\" Notes feature, NOT the \"Notes & Attachments\" feature being deprecated.\n *\n * @url https://salesforce.stackexchange.com/questions/104408/how-can-i-create-an-account-note-or-contact-note-via-api-that-is-visible-in-sale\n */\n private function buildEnhancedNote(string $title, string $body, string $objectId): string\n {\n // Decode stored entities, escape HTML (without quoting), then convert line breaks for Salesforce formatting\n $decodedBody = html_entity_decode($body, ENT_QUOTES | ENT_HTML5);\n $sanitizedBody = htmlspecialchars($decodedBody, ENT_NOQUOTES, 'UTF-8', false);\n $content = nl2br($sanitizedBody, false);\n $note = [\n 'OwnerId' => $this->profile->crm_provider_id,\n 'Title' => $title,\n 'Content' => base64_encode($content),\n ];\n\n $noteId = $this->createRecord('ContentNote', $note);\n\n $link = [\n 'ContentDocumentId' => $noteId,\n 'LinkedEntityId' => $objectId,\n 'ShareType' => 'I',\n ];\n\n $this->createRecord('ContentDocumentLink', $link);\n\n return $noteId;\n }\n\n private function buildClassicNote(string $title, string $body, string $objectId): string\n {\n if (in_array($this->parseObjectType($objectId), [Field::OBJECT_TASK, Field::OBJECT_EVENT])) {\n $this->logger->info('[Salesforce] Summary not sent', [\n 'profile_id' => $this->profile->id,\n 'objectId' => $objectId,\n 'reason' => 'Classical Note does not support Task/Event relation',\n ]);\n\n return '';\n }\n\n $titleTrimmed = null;\n\n if (mb_strlen($title) > 80) {\n $titleTrimmed = substr($title, 0, 77) . '...';\n }\n $payload = [\n 'OwnerId' => $this->profile->crm_provider_id,\n 'IsPrivate' => false,\n 'Title' => $titleTrimmed ?? $title,\n 'Body' => $titleTrimmed ? $title . PHP_EOL . $body : $body,\n 'ParentId' => $objectId,\n ];\n\n return $this->createRecord('Note', $payload);\n }\n\n /**\n * @inheritdoc\n */\n public function find(string $name, array $scopes): array\n {\n if ($this->profile === null) {\n return [];\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $limitValues = ['limit' => $this->limit, 'offset' => $this->offset];\n $sosl = $queryBuilder->buildFindQuery($name, $scopes, $limitValues);\n\n $this->logger->info('[Salesforce] Find prospects', [\n 'profile_id' => $this->profile->id,\n 'sosl_query' => $sosl,\n 'search_string' => $name,\n 'scopes' => $scopes,\n ]);\n\n $data = Cache::remember($this->profile->id . $sosl, self::CACHE_TTL, function () use ($sosl) {\n $data = [];\n\n try {\n // Hit remote API.\n $objects = $this->queryHandler->search($sosl);\n\n // Build mapped list.\n foreach ($objects as $object) {\n $type = strtolower($object['attributes']['type']);\n\n $record = [\n 'crmId' => $object['Id'],\n 'name' => $object['Name'],\n 'prospectType' => $type,\n 'phoneNumbers' => [],\n 'crmUrl' => $this->generateProviderUrl($object['Id'], $type),\n ];\n\n switch ($type) {\n case 'lead':\n if (empty($object['Company']) === false) {\n $record['organization'] = $object['Company'];\n }\n\n if (empty($object['Title']) === false) {\n $record['title'] = $object['Title'];\n }\n\n $stage = $this->config->stages()\n ->where('type', Stage::TYPE_LEAD)\n ->where('name', $object['Status'])\n ->first();\n\n // Lazy create the stage.\n if ($stage === null) {\n $stage = $this->importStages([Stage::TYPE_LEAD], $object['Status']);\n }\n\n if ($stage) {\n $record += [\n 'stage' => [\n 'id' => $stage->id_string,\n 'name' => $stage->name,\n ],\n ];\n }\n\n if (empty($object['RecordTypeId']) === false) {\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $object['RecordTypeId'])\n ->first();\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n }\n\n break;\n\n case 'account':\n if (empty($object['Industry']) === false) {\n $record['industry'] = $object['Industry'];\n $record['detailsLine'] = $object['Industry'];\n }\n if (! empty($object['PersonEmail'])) {\n $record['detailsLine'] = $object['PersonEmail'];\n }\n\n break;\n\n case 'contact':\n // For contacts, we should try and fetch their account name too.\n if ($object['AccountId']) {\n // Cheaper to get this locally.\n $account = $this->config->accounts()\n ->where('crm_provider_id', $object['AccountId'])\n ->first(['name']);\n\n if ($account) {\n $record['organization'] = $account->name;\n }\n }\n\n if (! empty($object['IsPersonAccount']) && $object['Email']) {\n $record['detailsLine'] = $object['Email'];\n } else {\n if (empty($object['Title']) === false) {\n $record['title'] = $object['Title'];\n }\n }\n\n break;\n }\n\n // Add phone numbers to record.\n if (empty($object['Phone']) === false && $object['Phone']) {\n $record['phoneNumbers'][] = [\n 'number' => $object['Phone'],\n 'nationalFormat' => phone_national($this->profile->user->country_code, $object['Phone']),\n 'type' => 'phone',\n ];\n }\n\n if (empty($object['MobilePhone']) === false && $object['MobilePhone']) {\n $record['phoneNumbers'][] = [\n 'number' => $object['MobilePhone'],\n 'nationalFormat' => phone_national(\n $this->profile->user->country_code,\n $object['MobilePhone']\n ),\n 'type' => 'mobile',\n ];\n }\n\n $data[] = $record;\n }\n } catch (NoResultsException $e) {\n $data = [];\n }\n\n return $data;\n });\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n\n if ($crmAccountId === null) {\n return $data;\n }\n\n if ($userId) {\n $profileRepository = app(ProfileRepository::class);\n $profile = $profileRepository->findProfileByUserId($this->config, $userId);\n\n $ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;\n }\n\n try {\n // Perhaps their profile has no opportunity permissions.\n if ($this->profile === null || $this->profile->opportunity_fields === null) {\n return $data;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $query = $queryBuilder->buildFindOpportunitiesQuery();\n\n $objects = $this->queryHandler->query($query, ['accountId' => $crmAccountId]);\n\n foreach ($objects as $object) {\n $record = [\n 'crmId' => $object['Id'],\n 'name' => $object['Name'],\n 'won' => $object['IsWon'],\n 'closed' => $object['IsClosed'],\n ];\n\n $valueFieldName = 'Amount';\n if ($this->config->opportunity_value_field_id) {\n $valueFieldName = $this->config->opportunityValueField->crm_provider_id;\n }\n\n if (empty($object[$valueFieldName]) === false) {\n $currency = $object['CurrencyIsoCode'] ?? $this->config->default_currency;\n $value = formatCurrency($object[$valueFieldName], $currency);\n\n $record += [\n 'value' => $value,\n ];\n }\n\n $stage = $this->config->stages()\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->where('name', $object['StageName'])\n ->first();\n\n // Lazy create the stage.\n if ($stage === null) {\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $object['StageName']);\n }\n\n $record += [\n 'stage' => [\n 'id' => $stage->id_string,\n 'name' => $stage->name,\n ],\n ];\n\n if (empty($object['RecordTypeId']) === false) {\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $object['RecordTypeId'])\n ->first();\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n }\n\n if ($ownerId && isset($object['OwnerId']) && $object['OwnerId'] === $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n } catch (NoResultsException $e) {\n return $data;\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n public function getContactRolesFromCrm(?Carbon $since = null): array\n {\n $roles = [];\n\n if ($this->profile === null) {\n return $roles;\n }\n\n $queryBuilder = app(QueryBuilder::class, ['profile' => $this->profile]);\n\n $query = $queryBuilder->buildGetContactRolesQuery($since);\n\n try {\n $objects = $this->queryHandler->query($query);\n\n foreach ($objects as $object) {\n $roles[] = [\n 'id' => $object['Id'],\n 'contactId' => $object['ContactId'],\n 'opportunityId' => $object['OpportunityId'],\n 'ownerId' => $object['Opportunity']['OwnerId'] ?? null,\n 'isPrimary' => $object['IsPrimary'],\n 'role' => $object['Role'],\n ];\n }\n } catch (NoResultsException) {\n // Just return an empty array.\n $this->logger->info('[Salesforce] No contact roles found', [\n 'since' => $since?->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n }\n\n return $roles;\n }\n\n public function syncContactRoles(Carbon $since): int\n {\n $contactRoleRepository = app(ContactRoleRepository::class);\n\n $crmContactRoles = $this->getContactRolesFromCrm(since: $since);\n $syncCount = 0;\n $contactRoles = [];\n\n foreach ($crmContactRoles as $crmContactRole) {\n $contactRoles[] = $this->importContactRole($crmContactRole);\n $syncCount++;\n }\n\n $contactRoleRepository->saveContactRoles($contactRoles);\n\n $this->syncRemotelyDeletedContactRoles();\n\n return $syncCount;\n }\n\n private function importContactRole(array $contactRole): array\n {\n $contact = $this->config->contacts()\n ->where('crm_provider_id', $contactRole['contactId'])\n ->first();\n\n if ($contact === null) {\n $contact = $this->syncContact($contactRole['contactId']);\n }\n\n $opportunity = $this->config->opportunities()\n ->where('crm_provider_id', $contactRole['opportunityId'])\n ->first();\n\n if ($opportunity === null) {\n $opportunity = $this->syncOpportunity($contactRole['opportunityId']);\n }\n\n $role = null;\n if (! empty($contactRole['role'])) {\n $role = mb_strimwidth($contactRole['role'], 0, 191);\n }\n\n return [\n 'crm_configuration_id' => $this->config->getId(),\n 'contact_id' => $contact->getId(),\n 'crm_provider_id' => $contactRole['id'],\n 'subject_type' => ContactRole::SUBJECT_TYPE_OPPORTUNITY,\n 'subject_id' => $opportunity->getId(),\n 'is_primary' => $contactRole['isPrimary'],\n 'role' => $role,\n ];\n }\n\n protected function syncRemotelyDeletedContactRoles(): bool\n {\n try {\n $deletedRemotely = $this->queryHandler->queryDeleted('OpportunityContactRole');\n } catch (NoResultsException $e) {\n return false;\n }\n\n $deletedOpportunities = $deletedRemotely->getResults();\n $deletedIds = array_column($deletedOpportunities, 'id');\n\n $contactRoleRepository = app(ContactRoleRepository::class);\n\n foreach (array_chunk($deletedIds, self::HARD_DELETE_CHUNK) as $chunk) {\n $contactRoleRepository->deleteContactRoles($chunk);\n\n $this->logger->info('[' . $this->getDisplayName() . '] Remotely deleted opportunities synced', [\n 'teamId' => $this->team->id_string,\n 'remotelyDeletedOpportunities' => $chunk,\n 'count' => count($chunk),\n ]);\n }\n\n return true;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = $objects = [];\n\n $hasWho = \\in_array($objectType, ['lead', 'contact']);\n $playbook = $this->getPlaybook($this->profile->user);\n $fields = array_merge(\n $this->profile->getFieldsAsArray(parent::OBJECT_TASK),\n $playbook && $playbook->activityField ? [$playbook->activityField->crm_provider_id] : []\n );\n\n // Query should default to any open call for that user.\n $query = '\n SELECT ' . implode(',', array_unique($fields)) . '\n FROM Task\n WHERE OwnerId = :ownerId\n AND IsArchived = false\n AND IsDeleted = false\n AND IsClosed = false\n AND (';\n\n if ($objectType === 'account') {\n // This covers tasks tied to a related contact or opportunity too.\n $query .= '\n AccountId = :accountId';\n }\n\n if ($hasWho) {\n $query .= '\n WhoId = :whoId';\n\n // If we are also going to check on a specific opportunity, set that up.\n if ($opportunityId) {\n $query .= ' OR WhatId = :whatId';\n }\n }\n\n $query .= ' ) ORDER BY LastModifiedDate DESC';\n\n try {\n $objects = $this->queryHandler->query($query, [\n 'ownerId' => $this->profile->crm_provider_id,\n 'whoId' => $objectId,\n 'whatId' => $opportunityId,\n 'accountId' => $objectId,\n ]);\n } catch (NoResultsException $e) {\n return $data;\n } finally {\n $this->logger->debug(sprintf('[Salesforce] Found %s tasks for query \"%s\"', count($objects), $query));\n }\n\n foreach ($objects as $object) {\n $dueDate = $object['ActivityDate'] ? Carbon::parse($object['ActivityDate'])->toIso8601String() : null;\n $data[] = [\n 'crmId' => $object['Id'],\n 'subject' => $object['Subject'],\n 'due' => $dueDate,\n 'type' => $object[$playbook->activityField->crm_provider_id],\n ];\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getEvents(string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = $objects = [];\n $user = $this->profile?->user;\n if ($this->profile === null || $user === null) {\n return $data;\n }\n\n $hasWho = \\in_array($objectType, ['lead', 'contact']);\n $playbook = $this->getPlaybook($user);\n $fields = array_merge(\n $this->profile->getFieldsAsArray(parent::OBJECT_EVENT),\n $playbook && $playbook->activityField ? [$playbook->activityField->crm_provider_id] : []\n );\n\n // Query should default to any event starting in the last week and ending up until today owned by the user.\n $query = '\n SELECT ' . implode(',', array_unique($fields)) . '\n FROM Event\n WHERE OwnerId = :ownerId\n AND IsArchived = false\n AND IsAllDayEvent = false\n AND StartDateTime >= LAST_N_DAYS:7\n AND EndDateTime <= TODAY\n AND (';\n\n if ($objectType === 'account') {\n // This covers events tied to a related contact or opportunity too.\n $query .= '\n AccountId = :accountId';\n }\n\n if ($hasWho) {\n $query .= '\n WhoId = :whoId';\n\n // If we are also going to check on a specific opportunity, set that up.\n if ($opportunityId) {\n $query .= ' OR WhatId = :whatId';\n }\n }\n\n $query .= ' ) ORDER BY LastModifiedDate DESC';\n\n try {\n $objects = $this->queryHandler->query($query, [\n 'ownerId' => $this->profile->crm_provider_id,\n 'whoId' => $objectId,\n 'whatId' => $opportunityId,\n 'accountId' => $objectId,\n ]);\n } catch (NoResultsException $e) {\n return $data;\n } finally {\n $this->logger->debug(sprintf('[Salesforce] Found %s tasks for query \"%s\"', count($objects), $query));\n }\n\n foreach ($objects as $object) {\n $dueDate = $object['StartDateTime'] ? Carbon::parse($object['StartDateTime'])->toIso8601String() : null;\n\n $data[] = [\n 'crmId' => $object['Id'],\n 'subject' => $object['Subject'],\n 'due' => $dueDate,\n 'type' => $object[$playbook->activityField->crm_provider_id],\n ];\n }\n\n return $data;\n }\n\n /**\n * Try to find CRM Objects using email address\n *\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchExactlyByEmail(string $email, ?int $userId = null): ?array\n {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $sosl = $queryBuilder->buildMatchByQuery($email, Field::TYPE_EMAIL);\n if ($sosl === null) {\n return null;\n }\n\n try {\n $objects = $this->queryHandler->search($sosl);\n $objects = $this->queryHandler->prioritiseResults(\n $objects,\n $email,\n QueryHandler::PRIORITISE_EMAIL\n );\n\n $data = $this->convertCrmData($objects, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (NoResultsException $e) {\n // Try the account next.\n if ($this->profile->account_fields === null) {\n return null;\n }\n }\n\n return null;\n }\n\n public function getDomain(string $email): ?string\n {\n // SF improved search - strip the domain extension, min domain name length 4\n return $this->getCompanyNameFromEmail(email: $email, minNameLength: 4);\n }\n\n /**\n * Try to find CRM objects using domain name of the email address\n *\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByDomain(string $domain, ?int $userId = null): ?array\n {\n $companyName = $domain;\n\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $sosl = $queryBuilder->buildMatchByDomainQuery($companyName);\n\n try {\n $objects = $this->queryHandler->search($sosl);\n\n $data = $this->convertCrmData($objects, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (NoResultsException) {\n return null;\n }\n }\n\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n // Don't bother looking up numbers that are masked.\n if (str_contains($phone, '**')) {\n return null;\n }\n\n if ($this->isPhoneNumberOfTeamMember($phone)) {\n return null;\n }\n\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $phoneNational = phone_national(null, $phone) ?? '';\n $possiblePhoneFormats = collect([\n preg_replace('/\\D/', '', ltrim($phone, '0+')),\n preg_replace('/\\D/', '', $phoneNational),\n formatDashPhoneNumber($phone),\n $phoneNational,\n ])\n ->filter() // Removes null and empty strings\n ->unique()\n ->values();\n\n foreach ($possiblePhoneFormats as $phone) {\n $sosl = $queryBuilder->buildMatchByQuery($phone, Field::TYPE_PHONE);\n if ($sosl === null) {\n continue;\n }\n\n try {\n $objects = $this->queryHandler->search($sosl);\n $objects = $this->queryHandler->prioritiseResults(\n $objects,\n $phone,\n QueryHandler::PRIORITISE_PHONE\n );\n\n return $this->convertCrmData($objects, $userId);\n } catch (NoResultsException) {\n continue;\n }\n }\n\n return null;\n }\n\n private function isPhoneNumberOfTeamMember(string $phone): bool\n {\n $teamRepository = app(TeamRepository::class);\n $user = $teamRepository->findTeamMemberByPhone($this->team, $phone);\n\n if ($user instanceof User) {\n return true;\n }\n\n return false;\n }\n\n protected function getCacheKey(string $object, ?int $userId = null): ?string\n {\n $key = $this->profile->id . $object;\n $keySuffix = $this->getOwnerKeySuffix($userId);\n\n return $key . $keySuffix;\n }\n\n private function getOwnerKeySuffix(?int $userId = null): string\n {\n return $userId === null ? '' : (string) $userId;\n }\n\n /** Determine the CRM Objects which represent the call activity. */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n if ($this->profile === null) {\n return null;\n }\n\n $cacheKey = $this->getCacheKey($name, $userId);\n\n $result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $sosl = $queryBuilder->buildMatchByQuery($name, 'name');\n if ($sosl === null) {\n return false;\n }\n\n try {\n $objects = $this->queryHandler->search($sosl);\n } catch (NoResultsException $e) {\n return false;\n }\n\n $objects = $this->queryHandler->prioritiseResults(\n $objects,\n $name,\n QueryHandler::PRIORITISE_NAME\n );\n\n $data = $this->convertCrmData($objects, $userId);\n\n return (! empty(array_filter($data))) ? $data : false;\n });\n\n return is_array($result) ? $result : null;\n }\n\n /**\n * @return array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n protected function convertCrmData(QueryIterator $objects, ?int $userId = null): array\n {\n $lead = null;\n $contact = null;\n $opportunity = null;\n $account = null;\n $stage = null;\n $countryCode = null;\n\n if ($objects->count() > 0) {\n $object = $objects->current();\n\n if ($object['attributes']['type'] === 'Lead') {\n $lead = $this->importLead($object);\n\n // Lead might not be imported if the Stage is null for example.\n if ($lead) {\n $countryCode = $lead->country_code;\n $stage = $lead->stage;\n }\n } else {\n if ($object['attributes']['type'] === 'Contact') {\n $contact = $this->importContact($object);\n $account = $contact->account;\n } else {\n $account = $this->importAccount($object);\n }\n\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account) {\n $countryCode = $account->country_code;\n }\n\n try {\n $sfOpportunities = $this->findOpportunities(\n $account?->getCrmProviderId(),\n $contact?->getCrmProviderId(),\n $userId\n );\n\n // Take the first opportunity, which will be ordered as priority based on their settings.\n if (! empty($sfOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($sfOpportunities[0]['crmId']);\n $stage = $opportunity?->stage;\n }\n } catch (Exception) {\n // Nothing to see here.\n }\n }\n }\n\n return [\n $lead,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n /**\n * @inheritdoc\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n if ($stage->type === Stage::TYPE_LEAD) {\n $objectType = 'Lead';\n $objectId = $crmObject->crm_provider_id;\n $objectStageType = 'Status';\n } else {\n $objectType = 'Opportunity';\n $objectId = $crmObject->crm_provider_id;\n $objectStageType = 'StageName';\n }\n\n $headers = [];\n if ($this->config->trigger_assignment_rules === false) {\n // @see: https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/headers_autoassign.htm\n $headers = [\n 'Sforce-Auto-Assign' => 'false',\n ];\n }\n\n $this->updateRecord($objectType, $objectId, [$objectStageType => $stage->name], $headers);\n }\n\n public function parseObjectType(string $objectId): string\n {\n if (Str::startsWith($objectId, '001')) {\n return 'account';\n }\n\n if (Str::startsWith($objectId, '003')) {\n return 'contact';\n }\n\n if (Str::startsWith($objectId, '00Q')) {\n return 'lead';\n }\n\n if (Str::startsWith($objectId, '006')) {\n return 'opportunity';\n }\n\n if (Str::startsWith($objectId, '00U')) {\n return 'event';\n }\n\n if (Str::startsWith($objectId, '00T')) {\n return 'task';\n }\n\n throw new \\InvalidArgumentException('Unsupported Object Type');\n }\n\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, ['profile' => $this->profile]);\n $query = $queryBuilder->buildGetUsersQuery($userToSearch);\n\n try {\n $salesforceUsers = $this->queryHandler->query($query, [\n 'active' => true,\n ]);\n } catch (NoResultsException $e) {\n $this->logger->info('[Salesforce] Sync Profiles. No users found', [\n 'query' => $query,\n 'error' => $e->getMessage(),\n ]);\n\n return null;\n }\n\n $teamRepository = app(TeamRepository::class);\n $customRules = $this->getCustomProfileRules($teamRepository);\n\n foreach ($salesforceUsers as $crmUser) {\n if ($crmUser['Email'] === null) {\n continue;\n }\n\n if (! $this->customProfileValidation($crmUser, $customRules)) {\n continue;\n }\n\n $user = $teamRepository->findActiveTeamMemberByEmail($this->team, $crmUser['Email']);\n\n if (! $user instanceof User) {\n continue;\n }\n\n $edition = $crmUser['UserPreferencesLightningExperiencePreferred']\n ? Profile::EDITION_LIGHTNING\n : Profile::EDITION_CLASSIC;\n\n $profileRepository = app(ProfileRepository::class);\n $profile = $profileRepository->updateOrCreateProfile(\n $user,\n [\n 'crm_configuration_id' => $this->config->getId(),\n 'crm_provider_id' => $crmUser['Id'],\n ],\n [\n 'user_id' => $user->getId(),\n 'edition' => $edition,\n 'has_external_cti' => ! empty($crmUser['CallCenterId']),\n 'crm_profile_id' => $crmUser['ProfileId'],\n ]\n );\n\n if ($userToSearch instanceof User && $userToSearch->getId() === $user->getId()) {\n return $profile;\n }\n }\n\n // Clean up inactive profiles\n try {\n $this->archiveInactiveProfiles();\n } catch (\\Exception $e) {\n $this->logger->warning('[Salesforce] Profile archiving failed', [\n 'teamId' => $this->team->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $url = null;\n\n // For Salesforce it's easy, we just point every object to the apex domain and they handle it.\n switch ($objectType) {\n case 'lead':\n case 'account':\n case 'contact':\n case 'opportunity':\n case 'task':\n case 'event':\n case 'activity':\n\n $url = $this->config->crm_base_url . '/' . $providerId;\n\n break;\n }\n\n return $url;\n }\n\n public function buildTaskSearchFields(): array\n {\n return ['Id', 'WhoId', 'WhatId', 'AccountId'];\n }\n\n public function getTaskByFilterConditions(\n array $fields,\n array $filters,\n bool $bulkSearch = false,\n bool $strictFilters = true\n ): ?array {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $query = $queryBuilder->buildSearchTaskQuery($fields, $filters, $bulkSearch, $strictFilters);\n\n try {\n if (! $bulkSearch) {\n $objects = $this->queryHandler->query($query, $filters);\n if ($objects->count() === 1) {\n return $objects->current();\n }\n }\n\n if ($bulkSearch) {\n $objects = $this->queryHandler->query($query);\n $records = [];\n foreach ($objects as $record) {\n $key = $record[end($fields)];\n $records[$key] = $record;\n }\n\n return $records;\n }\n } catch (\\Exception $e) {\n $this->logger->info('[Salesforce] Failed to execute query', [\n 'query' => $query,\n 'error' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function mapCrmObjects(array $task): array\n {\n $activityData = [];\n\n if (! empty($task['WhoId'])) {\n $type = $this->parseObjectType($task['WhoId']);\n $activityData[$type] = $task['WhoId'];\n }\n if (! empty($task['AccountId'])) {\n $activityData['account'] = $task['AccountId'];\n }\n if (! empty($task['WhatId'])) {\n $activityData['opportunity'] = $task['WhatId'];\n }\n\n return $activityData;\n }\n\n /**\n * Get SF task by Outreach call id.\n */\n public function getTaskByFilter(\n string $activityFieldType,\n array $filters,\n string $operator = '=',\n array $additionalFields = []\n ): ?array {\n $data = [];\n\n try {\n // Default (base) fields.\n $fields = ['Id', 'Subject', 'Description', 'ActivityDate', 'WhoId', 'WhatId', $activityFieldType];\n\n foreach ($additionalFields as $additionalField) {\n $fields[] = $additionalField->crm_provider_id;\n }\n\n $fields = array_unique($fields);\n\n // Find task with the same Outreach id as the call id.\n $query = 'SELECT ' . implode(',', $fields) . '\n FROM Task\n WHERE IsArchived = false AND IsDeleted = false';\n\n foreach ($filters as $key => $value) {\n $key = preg_quote($key, '/');\n $key = str_replace(['\\'', '\"'], '', $key);\n // Prepare the substitution.\n $strKey = \":$key\";\n\n $query .= \" AND $key $operator $strKey\";\n }\n\n $query .= ' ORDER BY LastModifiedDate DESC LIMIT 1';\n\n $objects = $this->queryHandler->query($query, $filters);\n\n // There should be only one task related to this call if any.\n if ($objects->count() === 1) {\n $object = $objects->current();\n\n $dueDate = $object['ActivityDate'] ? Carbon::parse($object['ActivityDate'])->toIso8601String() : null;\n\n $data = array_merge($object, [\n 'crmId' => $object['Id'],\n 'subject' => $object['Subject'],\n 'summary' => $object['Description'],\n 'due' => $dueDate,\n 'Type' => $object[$activityFieldType],\n ]);\n }\n } catch (NoResultsException $e) {\n // Filters don't match any records.\n } catch (ServiceUnavailableException $serviceUnavailableException) {\n // Service cannot be queried. We should probably log this.\n }\n\n return $data;\n }\n\n /**\n * Get Salesforce fields including datetime fields\n *\n * @param $objectType\n */\n private function getAllFieldsAsArray($objectType): array\n {\n $basicFields = [];\n // Not all users have access to all object fields.\n if ($this->profile->{$objectType . '_fields'}) {\n $basicFields = explode(',', $this->profile->{$objectType . '_fields'});\n }\n\n $extraFields = [\n 'CreatedDate',\n 'LastModifiedDate',\n 'IsDeleted',\n ];\n\n if ($objectType === self::OBJECT_OPPORTUNITY\n && $this->config->opportunity_value_field_id\n && ! in_array($this->config->opportunityValueField->crm_provider_id, $basicFields)\n ) {\n $extraFields[] = $this->config->opportunityValueField->crm_provider_id;\n }\n\n return array_unique(array_merge($basicFields, $extraFields));\n }\n\n /**\n * Generate transcription for activity description.\n */\n private function generateTranscription(Activity $activity): string\n {\n if (! ($this->config->store_transcript)) {\n // If sending transcription to activity toggle is disabled\n return '';\n }\n\n return $this->transcriptionService\n ->findTranscriptionByActivity($activity)\n ->map(static function (array $transcriptionSegment): string {\n return $transcriptionSegment['formattedStartsAt'] . ' | ' . $transcriptionSegment['transcript'];\n })\n ->implode(PHP_EOL);\n }\n\n /**\n * Find related Salesforce event based on activity data\n *\n * @return array<string>\n */\n public function fetchRelatedActivity(Activity $activity): array\n {\n $this->logger->info('[Salesforce] Searching for related activity', [\n 'activityId' => $activity->getUuid(),\n 'ownerId' => $this->profile?->crm_provider_id,\n ]);\n\n $sfEvent = $this->fetchRelatedEvent($activity);\n if (empty($sfEvent)) {\n $this->logger->info('[Salesforce] No related activity found', [\n 'activityId' => $activity->getUuid(),\n 'ownerId' => $this->profile?->crm_provider_id,\n 'account' => $activity->hasAccount()\n ? $activity->getAccount()->getCrmProviderId()\n : null,\n ]);\n\n return [];\n }\n\n return $sfEvent;\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n if ($activity->isTypeConference() === false) {\n return null;\n }\n\n if ($activity->hasActualStartTime() === false && $activity->hasScheduledStartTime() === false) {\n return null;\n }\n\n if (! $activity->hasProspect()) {\n $this->logger->info('[Salesforce] Skip look up, Activity not linked to Lead, Contact or Account', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n $playbook = $this->getPlaybook($activity->getUser());\n if ($playbook !== null && $playbook->getActivityType() === Playbook::ACTIVITY_TYPE_TASK) {\n $this->logger->info('[Salesforce] Skip auto-sync for task-based playbook', [\n 'activityUuid' => $activity->getUuid(),\n 'playbookId' => $playbook->getId(),\n 'playbookType' => $playbook->getActivityType(),\n ]);\n\n return null;\n }\n\n try {\n $sfEvent = $this->fetchRelatedActivity($activity);\n if (empty($sfEvent)) {\n return null;\n }\n\n [$activityField, $activityType] = $this->resolveActivityTypeFromEvent($activity, $sfEvent);\n\n $this->logger->info('[Salesforce] Found related activity', [\n 'activityId' => $activity->getUuid(),\n 'sfEvent' => $sfEvent['Id'],\n 'activityFieldName' => $activityField,\n 'crmActivityType' => ($activityField !== null && isset($sfEvent[$activityField]))\n ? $sfEvent[$activityField]\n : null,\n 'activityType' => $activityType,\n ]);\n\n $userId = $this->findRelatedActivityUserId($activity, $sfEvent);\n\n if ($activity->getUserId() !== $userId) {\n $this->logger->info('[Salesforce] Updating meeting owner', [\n 'activityId' => $activity->getUuid(),\n 'oldUserId' => $activity->getUserId(),\n 'newUserId' => $userId,\n ]);\n }\n\n $this->updateSfEventDescription($activity, $sfEvent);\n\n $activity->update([\n 'user_id' => $userId,\n 'crm_provider_id' => $sfEvent['Id'],\n 'playbook_category_id' => $activityType->id ?? $activity->getCategory()?->getId(),\n ]);\n\n $this->logger->info('[Salesforce] Activity updated', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return $activity;\n } catch (\\Exception $exception) {\n \\Sentry::captureException($exception);\n\n throw $exception;\n }\n }\n\n /**\n * @param array<string, mixed> $sfEvent\n *\n * @return array{0: string|null, 1: mixed}\n */\n private function resolveActivityTypeFromEvent(Activity $activity, array $sfEvent): array\n {\n $activityField = $this->getActivityFieldName($activity);\n $activityType = null;\n\n if ($activityField !== null && ! empty($sfEvent[$activityField])) {\n $playbook = $this->getPlaybook($activity->getUser());\n $activityType = $this->getPlaybookCategory($playbook, strval($sfEvent[$activityField]));\n }\n\n return [$activityField, $activityType];\n }\n\n /**\n * @param array<string> $sfEvent\n */\n private function findRelatedActivityUserId(Activity $activity, array $sfEvent): int\n {\n $userId = $activity->getUserId();\n\n if (empty($sfEvent['OwnerId']) === false) {\n $profile = $this\n ->config\n ->profiles()\n ->where('crm_provider_id', $sfEvent['OwnerId'])\n ->get()\n ->filter(static function (Profile $profile) use ($activity): bool {\n if (! $activity->isTypeConference()) {\n return ! empty($profile->user) ? $profile->user->isStatusActive() : false;\n }\n\n $participants = $activity->getParticipants();\n\n return ! empty($profile->user)\n ? $profile->user->isStatusActive()\n && $profile->user->hasPermission(PermissionEnum::RECORD_MEETING)\n && $participants->contains('user_id', $profile->user_id)\n : false;\n })\n ->first();\n\n if ($profile) {\n $userId = $profile->user_id;\n }\n }\n\n return $userId;\n }\n\n /**\n * @param array<string, mixed> $sfEvent\n */\n private function updateSfEventDescription(Activity $activity, array $sfEvent): void\n {\n try {\n if (str_contains($sfEvent['Description'], $activity->id_string)) {\n return;\n }\n\n $payload = [\n 'Description' => $sfEvent['Description']\n . PHP_EOL\n . PHP_EOL\n . (new DecorateActivity())->generateDescription($activity),\n ];\n\n $this->logger->info('[Salesforce] Update record', [\n 'activityId' => $activity->getUuid(),\n 'sfEvent' => $sfEvent['Id'],\n 'payload' => $payload,\n ]);\n\n $payload = array_merge(\n $payload,\n $this->payloadBuilder->fetchCustomFieldData($activity, Field::OBJECT_EVENT)\n );\n\n $this->updateRecord('Event', $sfEvent['Id'], $payload);\n } catch (\\Exception) {\n $this->logger->error('[Salesforce] Failed to update record', [\n 'activityUuid' => $activity->getUuid(),\n 'sfEvent' => $sfEvent['Id'],\n ]);\n }\n }\n\n /**\n * Returns the most recently modified Event within time range (if any).\n *\n * @return array|null An Event record from Salesforce.\n */\n private function fetchRelatedEvent(Activity $activity): ?array\n {\n $ownerId = $this->profile?->crm_provider_id;\n if ($ownerId === null) {\n return [];\n }\n\n /** @var ?Carbon $from */\n /** @var ?Carbon $to */\n [$from, $to] = $this->getFromToDates($activity);\n\n try {\n $whoId = null;\n $hasWho = $activity->lead_id || $activity->contact_id;\n if ($hasWho) {\n $whoId = $activity->hasLead()\n ? $activity->getLead()->crm_provider_id\n : $activity->getContact()->crm_provider_id;\n }\n\n if ($hasWho === false && $activity->account_id === null) {\n return null;\n }\n\n $query = $this->buildFetchRelatedEventQuery($activity);\n\n $objects = $this->queryHandler->query($query, [\n 'ownerId' => $ownerId,\n 'whoId' => $whoId,\n 'whatId' => $activity->hasOpportunity() ? $activity->getOpportunity()->crm_provider_id : null,\n 'accountId' => $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null,\n 'from' => $from?->format('Y-m-d\\TH:i:s\\Z'),\n 'to' => $to?->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($objects as $object) {\n return $object;\n }\n } catch (NoResultsException $e) {\n return [];\n }\n\n return [];\n }\n\n private function getFromToDates(Activity $activity): array\n {\n $from = null;\n $to = null;\n\n /** @var ?CalendarEvent $calendarEvent */\n $calendarEvent = $activity->calendarEvent()->first();\n if ($calendarEvent !== null) {\n $from = $calendarEvent->getStartTime();\n $to = $calendarEvent->getEndTime();\n }\n\n // For non-calendar imported activities\n // Also double check if calendar event dates could be null?\n // If null use what we've got so far\n if ($from === null || $to === null) {\n $from = $activity->hasScheduledStartTime()\n ? $activity->getScheduledStartTime()\n : $activity->getActualStartTime();\n $to = $activity->hasScheduledEndTime()\n ? $activity->getScheduledEndTime()->addMinutes(15)\n : $activity->getActualEndTime();\n }\n\n return [$from, $to];\n }\n\n /**\n * Determines the appropriate activity field name for querying Salesforce events.\n *\n * This method follows a hierarchy to determine the field name:\n * 1. Uses the playbook's activity field if it exists and is in the profile's accessible fields\n * 2. Falls back to the default activity field if the profile has no event fields configured\n * 3. Returns null if no suitable field is found\n *\n * @param Activity $activity The activity to determine the field for\n *\n * @return string|null The field name to use in queries, or null if none is available\n */\n private function getActivityFieldName(Activity $activity): ?string\n {\n if ($this->profile === null) {\n $this->logger->warning('[Salesforce] Cannot determine activity field - profile not found', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n $profileEventFields = $this->profile->getFieldsAsArray('event');\n\n if (empty($profileEventFields)) {\n $defaultActivityField = $this->getDefaultActivityField(Field::OBJECT_EVENT);\n $defaultFieldName = $defaultActivityField?->getAttribute('crm_provider_id');\n // Profile not yet synced — fall back to the default activity field.\n // There is a small chance that the profile won't have Default Activity Type field access\n // in which case the query will fail.\n // This is however an edge case and should be reviewed for profile sync issues.\n Sentry::withScope(function (\\Sentry\\State\\Scope $scope) use ($defaultFieldName): void {\n $scope->setContext('details', [\n 'profileId' => $this->profile->id,\n 'defaultField' => $defaultFieldName,\n ]);\n Sentry::captureMessage(\n '[Salesforce] Profile event fields empty, falling back to default activity field.',\n \\Sentry\\Severity::warning()\n );\n });\n\n return $defaultFieldName;\n }\n\n $playbook = $this->getPlaybook($activity->getUser());\n\n if (! is_null($playbook) && ! is_null($playbook->getActivityField())) {\n $playbookFieldName = $playbook->getActivityField()->getAttribute('crm_provider_id');\n\n if (in_array($playbookFieldName, $profileEventFields, true)) {\n return $playbookFieldName;\n }\n\n $this->logger->warning('[Salesforce] Playbook activity field not found in profile fields', [\n 'activityId' => $activity->getUuid(),\n 'playbookField' => $playbookFieldName,\n 'profileId' => $this->profile->id,\n ]);\n }\n\n return null;\n }\n\n private function buildFetchRelatedEventQuery(Activity $activity): string\n {\n $hasWho = $activity->lead_id || $activity->contact_id;\n\n $activityFieldName = $this->getActivityFieldName($activity);\n $fields = array_filter(['Id', 'Description', 'OwnerId', $activityFieldName]);\n\n $ownerCondition = '(OwnerId = :ownerId OR CreatedById = :ownerId)';\n\n $query = '\n SELECT ' . implode(',', $fields) . '\n FROM Event\n WHERE ' . $ownerCondition . '\n AND IsArchived = false\n AND IsAllDayEvent = false\n AND StartDateTime >= :from\n AND EndDateTime <= :to\n AND (';\n\n $operator = '';\n if ($activity->account_id) {\n // This covers events tied to a related contact or opportunity too.\n $query .= 'AccountId = :accountId';\n\n $operator = ' OR ';\n }\n\n if ($hasWho) {\n $query .= $operator . 'WhoId = :whoId';\n\n // If we are also going to check on a specific opportunity, set that up.\n if ($activity->opportunity_id) {\n $query .= ' OR WhatId = :whatId';\n }\n }\n\n $query .= ') ORDER BY LastModifiedDate DESC';\n\n return $query;\n }\n\n public function fetchProspect(array $task): array\n {\n $lead = $account = $opportunity = $contact = $stage = $countryCode = null;\n $externalId = $task['WhoId'] ?? null;\n\n // Lead or Contact\n if ($externalId) {\n try {\n [$lead, $account, $opportunity, $contact, $stage, $countryCode] = $this->parseRecords($externalId);\n } catch (\\InvalidArgumentException $exception) {\n // Invalid object type.\n }\n }\n\n // If we happen to know the opportunity or account from the Task, figure that out.\n if (empty($task['WhatId']) === false) {\n // WhatId could be either Account ID or Opportunity ID.\n // If WhatId is Opportunity ID, get the opportunity and stage from the CRM.\n try {\n [, $account, $opportunity, , $stage, ] = $this->parseRecords($task['WhatId']);\n } catch (\\InvalidArgumentException $exception) {\n // Invalid object type.\n }\n }\n\n return [$lead, $account, $opportunity, $contact, $stage, $countryCode];\n }\n\n /**\n * Save activity transcription summary as note\n */\n public function saveTranscriptionSummaryAsNote(\n ActivityContract $activity,\n string $title,\n string $body,\n ?string $objectId,\n ?NoteObject $noteObject = null,\n ): ?string {\n return $this->saveNote($title, $body, (string) $objectId);\n }\n\n public function getObjectByFilterConditions(string $objectType, array $fields, array $filters): ?array\n {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $query = $queryBuilder->buildObjectSearchQuery($objectType, $fields, $filters);\n\n try {\n $objects = $this->queryHandler->query($query, $filters);\n if ($objects->count() === 1) {\n return $objects->current();\n }\n } catch (\\Exception $e) {\n $this->logger->info('[Salesforce] Failed to execute query', [\n 'query' => $query,\n 'error' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n private function getCustomProfileRules(TeamRepository $teamRepository): array\n {\n $teamSettings = $teamRepository->getTeamSetting($this->team, 'custom_profile_validation');\n\n if ($teamSettings instanceof TeamSettings && $teamSettings->getValueType() === 'array') {\n $customRules = json_decode($teamSettings->getValue(), true);\n if (is_array($customRules)) {\n return $customRules;\n }\n }\n\n return [];\n }\n\n private function customProfileValidation(array $crmUser, array $customRules): bool\n {\n foreach ($customRules as $customRule) {\n if ($crmUser[$customRule['field']] !== $customRule['value']) {\n return false;\n }\n }\n\n return true;\n }\n\n /**\n * When syncing Contact / Lead / Account / Opportunity / Stage crm entities,\n * validate and restore locally trashed objects,\n * before updating them. Objects are identified by CrmProviderId\n */\n private function restoreAnyTrashedEntity(HasMany $targetEntity, string $crmProviderId): void\n {\n $recordExists = $targetEntity->withTrashed()->where(['crm_provider_id' => $crmProviderId])->first();\n if ($recordExists && $recordExists->trashed()) {\n $recordExists->restore();\n }\n }\n\n #[\\Override] public function supportsNotes(): bool\n {\n return true;\n }\n\n private function getOwnerProfile(?string $ownerId): ?Profile\n {\n if ($ownerId === null) {\n return null;\n }\n\n return $this->config->profiles()\n ->where('crm_provider_id', $ownerId)\n ->first();\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"Current version","depth":1,"bounds":{"left":0.4398271,"top":0.43415803,"width":0.13863032,"height":0.013567438},"on_screen":true,"value":"Current version","help_text":"text/plain","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Salesforce;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse Illuminate\\Database\\Eloquent\\Relations\\HasMany;\nuse Illuminate\\Events\\Dispatcher;\nuse Illuminate\\Support\\Facades\\Cache;\nuse Illuminate\\Support\\Facades\\Log;\nuse Illuminate\\Support\\Str;\nuse Jiminny\\Component\\Country\\CountriesMap;\nuse Jiminny\\Contracts\\Acl\\PermissionEnum;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\FetchRelatedActivityInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\ImportsBusinessProcessesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\SalesforceBatchSyncInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\SalesforceInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteNoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SearchTaskInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SendSummaryToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SupportsObjectTypeParseInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmProfileRecordTypesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Enums\\CrmObject;\nuse Jiminny\\Events\\Activities\\Crm\\LeadConverted;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpBadRequestException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Exceptions\\NoResultsException;\nuse Jiminny\\Exceptions\\ServiceUnavailableException;\nuse Jiminny\\Jobs\\Crm\\NoteObject;\nuse Jiminny\\Jobs\\Crm\\MatchActivitiesToNewOpportunity;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Calendar\\CalendarEvent;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Contracts\\ActivityContract;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Configuration;\nuse Jiminny\\Models\\Crm\\ContactRole;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Crm\\RecordType;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\TeamSettings;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\ContactRoleRepository;\nuse Jiminny\\Repositories\\Crm\\FieldRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Repositories\\Crm\\RecordTypeFieldValuesRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\Helpers\\ArrayIterator;\nuse Jiminny\\Services\\Crm\\MatchDomainByEmailInterface;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Services\\Crm\\ResolveCompanyNameByEmailTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\Fields\\FieldHelper;\nuse Jiminny\\Services\\Crm\\Salesforce\\Fields\\FieldTypeConverter;\nuse Jiminny\\Services\\Crm\\Salesforce\\Fields\\ValueNormalizer;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\FollowupActivityTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\LogActivityTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\RecordManipulationsTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\SyncFieldsTrait;\nuse Jiminny\\Utils\\CurrencyFormatter;\nuse Jiminny\\Utils\\StringUtil;\nuse Ramsey\\Uuid\\Uuid;\nuse Sentry\\Laravel\\Facade as Sentry;\n\nclass Service extends BaseService implements\n SalesforceInterface,\n SalesforceBatchSyncInterface,\n SyncCrmEntitiesInterface,\n SyncCrmProfileRecordTypesInterface,\n ImportsBusinessProcessesInterface,\n RemoteEntityManipulationInterface,\n FetchRelatedActivityInterface,\n SendSummaryToCrmInterface,\n MatchDomainByEmailInterface,\n SearchTaskInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n SupportsObjectTypeParseInterface,\n RemoteNoteEntityManipulationInterface,\n VerifyTaskExistsInterface\n{\n use ResolveCompanyNameByEmailTrait;\n use SyncFieldsTrait;\n use DeleteObjectsTrait;\n use RecordManipulationsTrait;\n use ServiceTraits\\BatchSyncTrait;\n use FollowupActivityTrait;\n use LogActivityTrait;\n\n /**\n * Note Body Limit for the Old Note-Taking Tool\n *\n * @var int\n */\n private const int CLASSIC_NOTE_MAX_LENGTH = 32000;\n\n /**\n * Note Content Limit for the New Notes\n *\n * @var int\n */\n private const int ENHANCED_NOTE_MAX_LENGTH = 50000000;\n\n private const string INSTALLED_PACKAGE_ID = '033Tw0000007bKbIAI';\n\n private const int CACHE_TTL = 600;\n\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day - 86400\n\n /**\n * @var Client\n */\n protected $client;\n\n protected PayloadBuilder $payloadBuilder;\n protected QueryHandler $queryHandler;\n\n private OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n\n public function __construct(\n Client $client,\n PayloadBuilder $payloadBuilder,\n protected Dispatcher $eventDispatcher,\n private readonly CountriesMap $countriesMap,\n private readonly ProspectPhotoPathService $prospectPhotoPathService,\n ) {\n parent::__construct();\n\n $this->client = $client;\n $this->payloadBuilder = $payloadBuilder;\n $this->queryHandler = app(QueryHandler::class, [\n 'client' => $this->client,\n 'logger' => $this->logger,\n ]);\n $this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n }\n\n public function getDisplayName(): string\n {\n return 'Salesforce';\n }\n\n public function getJobDelay(): int\n {\n return 1;\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n return $user->getSocialAccount(SocialAccount::PROVIDER_SALESFORCE);\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($activity, $crmProviderId) {\n $playbook = $this->getPlaybookFromActivity($activity);\n\n if ($playbook === null) {\n $this->logger->warning('[Salesforce] Cannot verify task - no playbook found', [\n 'activity' => $activity->getId(),\n 'crm_provider_id' => $crmProviderId,\n ]);\n\n return false;\n }\n\n $objectType = $playbook->getActivityType() === Playbook::ACTIVITY_TYPE_EVENT ? 'Event' : 'Task';\n\n try {\n $record = $this->getRecord($objectType, $crmProviderId, ['Id', 'IsDeleted']);\n\n return ! empty($record) && ($record['IsDeleted'] ?? false) === false;\n } catch (HttpNotFoundException|HttpBadRequestException) {\n $this->logger->info('[Salesforce] Activity record not found during verification', [\n 'activity' => $activity->getId(),\n 'object_type' => $objectType,\n 'crm_provider_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n });\n }\n\n public function query(string $queryToRun, array $parameters = []): QueryIterator\n {\n // Due to poorly designed external calls, this method cannot be entirely removed\n return $this->queryHandler->query($queryToRun, $parameters);\n }\n\n /*=========== Organization Information ===============*/\n\n /**\n * Get a list of all the API Versions for the instance.\n *\n * @throws CrmException\n *\n * @return mixed\n *\n */\n public function getApiVersions()\n {\n $url = $this->config->crm_base_url . '/services/data';\n\n $response = $this->client->get($url);\n\n return json_decode($response->getBody(), true);\n }\n\n /**\n * Gets the valid recordTypes for a given Salesforce Object via the describe API.\n */\n private function getRecordTypes(string $crmObject): array\n {\n $url = $this->client->getObjectsUrl() . $crmObject . '/describe';\n\n $response = $this->client->get($url);\n $jsonResponse = json_decode($response->getBody(), true);\n\n $fields = [];\n foreach ($jsonResponse['recordTypeInfos'] as $row) {\n $fields[] = ['recordTypeId' => $row['recordTypeId'], 'default' => $row['defaultRecordTypeMapping']];\n }\n\n return $fields;\n }\n\n /**\n * Convert raw field data into a format compatible with CRM APIs.\n */\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return ValueNormalizer::normalize($fieldType, $fieldValue, $internal);\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n $defaultFields = ($activityType === Playbook::ACTIVITY_TYPE_TASK)\n ? FieldDefinitions::defaultTaskFields()\n : FieldDefinitions::defaultEventFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n // Setup the activity field as the default Type.\n /** @var Field $activityField */\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'Type',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK, Playbook::ACTIVITY_TYPE_EVENT];\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n\n $fieldFilter = ($activityType === Playbook::ACTIVITY_TYPE_TASK)\n ? FieldDefinitions::taskFollowupFieldsFilter()\n : FieldDefinitions::eventFollowupFieldsFilter();\n\n foreach ($fieldFilter as $eachFilter) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $eachFilter);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n /**\n * This one is now called only when ImportActivityTypes is triggered or SyncFieldMetadata executed manually\n * Regular sync now uses SharedSyncFieldsTrait -> syncSingleObjectType\n * Needs to be replaced later on\n */\n public function syncField(Field $field): void\n {\n try {\n if (FieldHelper::isCustomField($field)) {\n $query = '\n SELECT\n Id, Metadata, TableEnumOrId\n FROM\n CustomField\n WHERE\n DeveloperName = :fieldName\n AND\n TableEnumOrId = :fieldType\n AND\n NamespacePrefix = :namespacePrefix';\n\n // We need to constrain the field lookup to the object, in case it's used in multiple places.\n $objectType = \\in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true)\n ? 'activity'\n : $field->object_type;\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'fieldName' => substr($field->crm_provider_id, 0, -\\strlen('__c')),\n 'fieldType' => ucfirst($objectType),\n\n // This is used to ensure we only consider the field within the org, not installed packages.\n 'namespacePrefix' => 'null',\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n // Sync field metadata.\n $metadata = $sfField['Metadata'];\n\n $field->description = mb_strimwidth($metadata['description'] ?? '', 0, 191);\n $field->label = mb_strimwidth($metadata['label'] ?? '', 0, Field::LABEL_MAX_LENGTH);\n $field->type = $this->convertFieldType($metadata['type'], $field->getEntityName());\n $field->is_mandatory = ($metadata['required'] === true);\n $field->length = $metadata['length'];\n $field->default_value = mb_strimwidth(trim($metadata['defaultValue'] ?? '', '\"'), 0, 191);\n $field->save();\n } else {\n $query = '\n SELECT\n Id, DataType, DeveloperName, Label, Length, Description\n FROM\n FieldDefinition\n WHERE\n DurableId = :entityName';\n\n $entityName = $field->getEntityName();\n $sfFields = $this->queryHandler->metadata($query, [\n 'entityName' => $entityName,\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n $convertedType = $this->convertFieldType($sfField['DataType'], $entityName);\n $label = mb_strimwidth($sfField['Label'], 0, Field::LABEL_MAX_LENGTH);\n\n if ($field->isBusinessType()) {\n $label = 'Opportunity Type';\n }\n\n $field->description = mb_strimwidth($sfField['Description'], 0, Field::DESCRIPTION_MAX_LENGTH);\n $field->label = $label;\n $field->type = $convertedType;\n $field->length = $sfField['Length'];\n $field->save();\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n private function convertFieldType(string $from, ?string $entityName = null): string\n {\n $converter = new FieldTypeConverter();\n\n return $converter->convert($from, $entityName);\n }\n\n /**\n * @inheritdoc\n */\n public function importPicklistValues(Field $field): array\n {\n $values = [];\n $fieldValues = [];\n\n try {\n if (FieldHelper::isCustomField($field)) {\n $query = '\n SELECT\n Id, Metadata, TableEnumOrId\n FROM\n CustomField\n WHERE\n DeveloperName = :fieldName\n AND\n TableEnumOrId = :fieldType\n AND\n NamespacePrefix = :namespacePrefix';\n\n // We need to constrain the field lookup to the object, in case it's used in multiple places.\n $objectType = \\in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true) ?\n 'activity' : $field->object_type;\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'fieldName' => substr($field->crm_provider_id, 0, -\\strlen('__c')),\n 'fieldType' => ucfirst($objectType),\n // This is used to ensure we only consider the field within the org, not installed packages.\n 'namespacePrefix' => 'null',\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n $valueSet = $sfField['Metadata']['valueSet'];\n\n if ($valueSet['valueSetName'] === null) {\n // Local picklist values can be obtained easily.\n $picklistValues = $valueSet['valueSetDefinition']['value'];\n } else {\n // But for some fields, we just get the Global Value Picklist pointer so need to do more work.\n $picklistValues = $this->importGlobalValuePicklistValues($valueSet['valueSetName']);\n }\n\n // Import all active values.\n foreach ($picklistValues as $i => $sfFieldValue) {\n // Setup default value.\n if ($sfFieldValue['default']) {\n $field->update(['default_value' => $sfFieldValue['valueName']]);\n }\n\n // This comes through as null if active (lol).\n if ($sfFieldValue['isActive'] !== false) {\n $values[] = [\n 'value' => $sfFieldValue['valueName'],\n 'label' => $sfFieldValue['valueName'],\n 'sequence' => $i,\n 'is_default' => $sfFieldValue['default'],\n ];\n }\n }\n } else {\n $objectFields = $this->getObjectFields($field->object_type);\n $fieldId = $field->crm_provider_id;\n\n // Only work with our field of interest.\n $objectField = array_filter($objectFields, function ($item) use ($fieldId) {\n return $item['name'] === $fieldId;\n });\n\n $objectField = array_shift($objectField);\n if (empty($objectField['picklistValues']) === false) {\n foreach ($objectField['picklistValues'] as $i => $sfFieldValue) {\n // Skip inactive values.\n if ($sfFieldValue['active'] === false) {\n continue;\n }\n\n // Setup default value.\n if ($sfFieldValue['defaultValue']) {\n $field->update(['default_value' => $sfFieldValue['value']]);\n }\n\n $values[] = [\n 'value' => $sfFieldValue['value'],\n 'label' => $sfFieldValue['label'],\n 'sequence' => $i,\n 'is_default' => $sfFieldValue['defaultValue'],\n ];\n }\n }\n }\n\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n $value['value'] = substr($value['value'] ?? '', 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n if (($key = array_search($value['value'], $fieldsToPurge, true)) !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n // Get IDs of the values to be deleted\n $valuesToDelete = $field->values()->whereIn('value', $fieldsToPurge);\n $valuesToDeleteIds = $valuesToDelete->pluck('id');\n if (! $valuesToDeleteIds->isEmpty()) {\n $recordTypeFieldValuesRepository = app(RecordTypeFieldValuesRepository::class);\n $recordTypeFieldValuesRepository->deleteForCrmFieldValueIds($valuesToDeleteIds->toArray());\n\n // Now safely delete from crm_field_values\n $valuesToDelete->delete();\n }\n\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n return $fieldValues;\n }\n\n /**\n * Gets values from Global Value Picklists.\n */\n private function importGlobalValuePicklistValues(string $picklistName): array\n {\n $query = '\n SELECT\n Metadata\n FROM\n GlobalValueSet\n WHERE\n DeveloperName = :picklistName\n LIMIT 1';\n\n try {\n $sfValues = $this->queryHandler->metadata($query, [\n 'picklistName' => $picklistName,\n ]);\n\n // There is always 1 result at this point.\n $sfValue = $sfValues->current();\n\n return $sfValue['Metadata']['customValue'];\n } catch (NoResultsException $noResultsException) {\n // Nothing returned.\n\n return [];\n }\n }\n\n /**\n * @inheritdoc\n */\n public function syncProfileRecordTypes(): void\n {\n $objectTypes = [\n 'lead',\n 'account',\n 'contact',\n 'opportunity',\n 'task',\n 'event',\n ];\n\n foreach ($objectTypes as $objectType) {\n try {\n $crmRecordTypes = $this->getRecordTypes(ucfirst($objectType));\n\n foreach ($crmRecordTypes as $crmRecordType) {\n // If the record type is default and not the Master type, set this.\n if ($crmRecordType['default'] && $crmRecordType['recordTypeId'] !== '012000000000000AAA') {\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $crmRecordType['recordTypeId'])\n ->first();\n\n if ($recordType) {\n $this->profile->{$objectType . '_record_type_id'} = $recordType->id;\n }\n }\n }\n } catch (HttpNotFoundException $exception) {\n Log::error('No access to ' . $objectType . ' object, skipping...');\n\n // XXX: should we log this fact somewhere?\n continue;\n }\n }\n\n if ($this->profile->isDirty()) {\n $this->profile->save();\n }\n }\n\n /**\n * Gets business processes.\n */\n public function importBusinessProcesses(): void\n {\n $query = '\n SELECT\n Id, IsActive, Name, TableEnumOrId\n FROM\n BusinessProcess\n WHERE\n TableEnumOrId IN (\\'Lead\\',\\'Opportunity\\')';\n\n try {\n $sfProcesses = $this->queryHandler->query($query);\n\n // Upsert all processes for the team.\n foreach ($sfProcesses as $sfProcess) {\n /** @var BusinessProcess $businessProcess */\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $sfProcess['Id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => $sfProcess['Name'],\n 'type' => $sfProcess['TableEnumOrId'] === 'Lead' ? 'lead' : 'opportunity',\n 'is_selectable' => $sfProcess['IsActive'],\n ]);\n\n $this->importBusinessProcessStages($businessProcess);\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n /**\n * Gets business process stages.\n */\n private function importBusinessProcessStages(BusinessProcess $businessProcess): void\n {\n $query = '\n SELECT\n Metadata\n FROM\n BusinessProcess\n WHERE\n Id = :processId';\n\n try {\n $stages = [];\n $sfProcessStages = $this->queryHandler->metadata($query, [\n 'processId' => $businessProcess->crm_provider_id,\n ]);\n\n // There is always 1 result at this point.\n $sfProcessStage = $sfProcessStages->current();\n\n // Upsert all processes for the team.\n foreach ($sfProcessStage['Metadata']['values'] as $sfProcessStage) {\n $sanitizedName = urldecode($sfProcessStage['valueName']); // Must decode: \"%2C\" becomes \",\" etc.\n\n $stage = $businessProcess->crm->stages()\n // This MUST match on label because this API doesn't use API Name.\n ->where('label', $sanitizedName)\n ->where('type', $businessProcess->type)\n ->where('is_selectable', 1)\n ->first();\n\n if ($stage) {\n $stages[] = $stage->id;\n }\n }\n\n $businessProcess->stages()->sync($stages);\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n /**\n * Gets record types.\n */\n public function importRecordTypes(): void\n {\n $query = '\n SELECT\n Id, IsActive, Name, BusinessProcessId, SobjectType\n FROM\n RecordType';\n\n try {\n $sfRecordTypes = $this->queryHandler->query($query);\n\n // Upsert all record types for the process.\n foreach ($sfRecordTypes as $sfRecordType) {\n $businessProcess = null;\n if ($sfRecordType['BusinessProcessId']) {\n $businessProcess = $this->config->businessProcesses()\n ->where('crm_provider_id', $sfRecordType['BusinessProcessId'])\n ->first();\n }\n\n /** @var RecordType $recordType */\n $recordType = $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $sfRecordType['Id'],\n ], [\n 'team_id' => $this->team->id,\n 'type' => mb_strtolower($sfRecordType['SobjectType']),\n 'name' => $sfRecordType['Name'],\n 'is_selectable' => $sfRecordType['IsActive'],\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n $this->importRecordTypeFieldValues($recordType);\n }\n } catch (NoResultsException $noResultsException) {\n // Do nothing.\n }\n }\n\n /**\n * Import record type - field value mappings. This only works for standard fields.\n */\n private function importRecordTypeFieldValues(RecordType $recordType): void\n {\n try {\n $query = '\n SELECT\n Metadata\n FROM\n RecordType\n WHERE\n Id = :recordTypeId';\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'recordTypeId' => $recordType->crm_provider_id,\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n // Sync field metadata.\n $picklists = $sfField['Metadata']['picklistValues'];\n\n foreach ($picklists as $picklist) {\n $field = $this->config->fields()->where([\n 'type' => Field::TYPE_PICKLIST,\n 'object_type' => $recordType->type,\n 'crm_provider_id' => $picklist['picklist'],\n ])->first();\n\n if ($field) {\n $fieldValues = [];\n\n foreach ($picklist['values'] as $value) {\n // Must decode: \"%2C\" becomes \",\" etc.\n $fieldValue = $field->values()\n ->where('value', urldecode($value['valueName']))\n ->first();\n\n if ($fieldValue) {\n $fieldValues[] = $fieldValue->id;\n }\n }\n\n $recordType->fieldValues()->sync($fieldValues);\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n /**\n * @inheritdoc\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $params = [];\n $missingStage = null;\n if ($types === null) {\n $types = [Stage::TYPE_LEAD, Stage::TYPE_OPPORTUNITY];\n }\n\n foreach ($types as $type) {\n if ($type === Stage::TYPE_LEAD) {\n $query = '\n SELECT\n Id, ApiName, MasterLabel, SortOrder\n FROM\n LeadStatus';\n } else {\n $query = '\n SELECT\n Id, ApiName, MasterLabel, IsActive, SortOrder, DefaultProbability\n FROM\n OpportunityStage';\n }\n\n if ($missingStageName) {\n $escapedStageName = ValueNormalizer::replaceQueryWithStringLiterals($missingStageName);\n\n $query .= ' WHERE ApiName = :stageName';\n\n $params = [\n 'stageName' => $escapedStageName,\n ];\n }\n\n try {\n $sfStages = $this->queryHandler->query($query, $params);\n } catch (NoResultsException $exception) {\n $sfStages = [];\n }\n\n $missingStage = null;\n\n // Upsert all stages for the team.\n foreach ($sfStages as $sfStage) {\n $selectable = true;\n if (array_key_exists('IsActive', $sfStage)) {\n $selectable = $sfStage['IsActive'];\n }\n\n $this->restoreAnyTrashedEntity($this->config->stages(), $sfStage['Id']);\n\n $stage = $this->config->stages()->updateOrCreate([\n 'crm_provider_id' => $sfStage['Id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($sfStage['ApiName'], 0, 50),\n 'label' => mb_strimwidth($sfStage['MasterLabel'], 0, 191),\n 'type' => $type,\n 'sequence' => $sfStage['SortOrder'] ?? 0,\n 'is_selectable' => $selectable,\n 'probability' => $sfStage['DefaultProbability'] ?? null,\n ]);\n\n if ($missingStageName && $missingStageName === $sfStage['ApiName']) {\n $missingStage = $stage;\n }\n }\n\n if ($missingStageName && $missingStage === null) {\n // If they requested a stage that still doesn't exist, it must be inactive so lazy create it.\n $missingStage = $this->config->stages()->create([\n 'crm_provider_id' => Uuid::uuid4(),\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($missingStageName, 0, 50),\n 'label' => mb_strimwidth($missingStageName, 0, 191),\n 'type' => $type,\n 'sequence' => 0,\n 'is_selectable' => 0,\n ]);\n }\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int\n {\n $syncCount = 0;\n $fields = $this->getAllFieldsAsArray('lead');\n if (\\in_array('Id', $fields, true) === false) {\n return $syncCount;\n }\n\n $query = '\n SELECT ' . rtrim(implode(',', $fields), ',') . '\n FROM Lead\n WHERE LastModifiedDate > :since\n ORDER BY LastModifiedDate ASC';\n\n try {\n $sfLeads = $this->queryHandler->query($query, [\n 'since' => $since->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($sfLeads as $sfLead) {\n // Only sync if previously imported.\n if ($this->hasLead($sfLead['Id'])) {\n $this->importLead($sfLead);\n $syncCount++;\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::LEAD);\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLead(string $crmId): ?Lead\n {\n $fields = $this->getAllFieldsAsArray('lead');\n\n $sfLead = $this->getRecord('Lead', $crmId, $fields);\n\n return $this->importLead($sfLead);\n }\n\n private function importLead($crmData): ?Lead\n {\n /** @var ?Stage $stage */\n $stage = null;\n if (isset($crmData['Status'])) {\n // Get the current stage.\n $stage = $this->config\n ->stages()\n ->where('name', $crmData['Status'])\n ->where('type', Stage::TYPE_LEAD)\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_LEAD], $crmData['Status']);\n }\n }\n\n // If we have no way of importing this, just return null :(\n if ($stage === null) {\n return null;\n }\n\n $countryCode = $crmData['CountryCode'] ?? null;\n\n // Salesforce allows custom \"countries\" to be created. Disregard these.\n if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {\n $countryCode = null;\n }\n\n // If we have no country code, try to parse it from the country name.\n if ($countryCode === null && empty($crmData['Country']) !== false) {\n $countryCode = $this->convertCountryNameToCode($crmData['Country']);\n }\n\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($crmData['Phone'] ?? '', 0, 25);\n $parsedNumber = parsePhoneNumber($countryCode, $number);\n\n $mobilePhone = null;\n if (empty($crmData['MobilePhone']) === false) {\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($crmData['MobilePhone'], 0, 25);\n $mobilePhone = phone_e164($countryCode, $number);\n }\n\n $convertedDate = null;\n $convertedAccount = null;\n $convertedOpportunity = null;\n $convertedContact = null;\n\n if ($crmData['IsConverted'] == 'true') {\n $convertedDate = $crmData['ConvertedDate'];\n\n if (empty($crmData['ConvertedAccountId']) === false) {\n $convertedAccount = $this->config\n ->accounts()\n ->where('crm_provider_id', $crmData['ConvertedAccountId'])\n ->first();\n\n if ($convertedAccount === null) {\n try {\n $convertedAccount = $this->syncAccount($crmData['ConvertedAccountId']);\n } catch (HttpNotFoundException $exception) {\n // Probably the user has no permissions to access the converted data.\n }\n }\n }\n\n if (empty($crmData['ConvertedOpportunityId']) === false) {\n $convertedOpportunity = $this->config\n ->opportunities()\n ->where('crm_provider_id', $crmData['ConvertedOpportunityId'])\n ->first();\n\n if ($convertedOpportunity === null) {\n try {\n $convertedOpportunity = $this->syncOpportunity($crmData['ConvertedOpportunityId']);\n } catch (HttpNotFoundException $exception) {\n // Probably the user has no permissions to access the converted data.\n }\n }\n }\n\n if (empty($crmData['ConvertedContactId']) === false) {\n $convertedContact = $this->team\n ->crm\n ->contacts()\n ->where('crm_provider_id', $crmData['ConvertedContactId'])\n ->first();\n\n if ($convertedContact === null) {\n try {\n $convertedContact = $this->syncContact($crmData['ConvertedContactId']);\n } catch (HttpNotFoundException $exception) {\n // Probably the user has no permissions to access the converted data.\n }\n }\n }\n }\n\n if (empty($crmData['Company'])) {\n $company = 'Unknown';\n } else {\n $company = mb_strimwidth($crmData['Company'], 0, 191);\n }\n\n $domain = null;\n if (empty($crmData['Website']) === false) {\n $domain = mb_strimwidth($crmData['Website'], 0, 191);\n $domain = StringUtil::resolveDomain($domain);\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $data = [\n 'team_id' => $this->team->id,\n 'user_id' => $profile?->user_id,\n 'owner_id' => $crmData['OwnerId'] ?? '',\n 'company' => $company,\n 'domain' => $domain,\n 'name' => $crmData['Name'] ? mb_strimwidth($crmData['Name'], 0, 191) : '',\n 'title' => $crmData['Title'] ? mb_strimwidth($crmData['Title'], 0, 128) : null,\n 'email' => $crmData['Email'] ? mb_strimwidth($crmData['Email'], 0, 80) : null,\n 'phone' => $parsedNumber['phone'],\n 'ext' => $parsedNumber['ext'] ?? null,\n 'mobile_phone' => $mobilePhone,\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['Id'],\n modelType: Lead::class,\n fileName: $crmData['Id'],\n avatarText: $crmData['Name']\n ),\n 'stage_id' => $stage->id,\n 'record_type_id' => null,\n 'converted_at' => $convertedDate,\n 'converted_account_id' => $convertedAccount->id ?? null,\n 'converted_opportunity_id' => $convertedOpportunity->id ?? null,\n 'converted_contact_id' => $convertedContact->id ?? null,\n 'country_code' => $countryCode,\n 'remotely_created_at' => $createdDate,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->leads(), $crmData['Id']);\n\n /** @var Lead $lead */\n $lead = $this->config->leads()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n if ($lead->wasChanged('converted_at') && $lead->getConvertedAt() !== null) {\n $this->eventDispatcher->dispatch(new LeadConverted($lead));\n }\n\n $this->handleObjectDeletion($lead, $crmData);\n\n if ($lead->trashed()) {\n return null;\n }\n\n return $lead;\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccounts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n $fields = $this->getAllFieldsAsArray('account');\n\n if (\\in_array('Id', $fields, true) === false) {\n return $syncCount;\n }\n\n $query = '\n SELECT ' . rtrim(implode(',', $fields), ',') . '\n FROM Account\n WHERE LastModifiedDate > :since\n ORDER BY LastModifiedDate ASC';\n\n try {\n $sfAccounts = $this->queryHandler->query($query, [\n 'since' => $since->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($sfAccounts as $sfAccount) {\n // Only sync if previously imported.\n if ($this->hasAccount($sfAccount['Id'])) {\n $this->importAccount($sfAccount);\n $syncCount++;\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::ACCOUNT);\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccount(string $crmId): ?Account\n {\n $fields = $this->getAllFieldsAsArray('account');\n if (! in_array('Id', $fields, true)) {\n $this->logger->info('[Salesforce] Sync account cancelled. Fields are not available.', [\n 'crmId' => $crmId,\n 'userId' => $this->profile->getUserId(),\n ]);\n\n return null;\n }\n\n $sfAccount = $this->getRecord('Account', $crmId, $fields);\n\n return $this->importAccount($sfAccount);\n }\n\n private function importAccount($crmData): Account\n {\n $countryCode = $crmData['BillingCountryCode'] ?? $crmData['ShippingCountryCode'] ?? null;\n\n // Salesforce allows custom \"countries\" to be created. Disregard these.\n if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {\n $countryCode = null;\n }\n\n // If we have no country code, try to parse it from the country names.\n if ($countryCode === null && empty($crmData['BillingCountry']) === false) {\n $countryCode = $this->convertCountryNameToCode($crmData['BillingCountry']);\n }\n\n if ($countryCode === null && empty($crmData['ShippingCountry']) === false) {\n $countryCode = $this->convertCountryNameToCode($crmData['ShippingCountry']);\n }\n\n if (empty($crmData['Phone']) === false) {\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($crmData['Phone'], 0, 25);\n $parsedNumber = parsePhoneNumber($countryCode, $number);\n } else {\n $parsedNumber = [];\n }\n\n $industry = null;\n if (empty($crmData['Industry']) === false) {\n $industry = mb_strimwidth($crmData['Industry'], 0, 40);\n }\n\n $domain = null;\n if (empty($crmData['Website']) === false) {\n $domain = mb_strimwidth($crmData['Website'], 0, 191);\n $domain = StringUtil::resolveDomain($domain);\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $data = [\n 'team_id' => $this->team->id,\n 'user_id' => $profile?->user_id,\n 'owner_id' => $crmData['OwnerId'],\n 'name' => mb_strimwidth($crmData['Name'], 0, 191),\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['Id'],\n modelType: Account::class,\n fileName: $crmData['Id'],\n avatarText: $crmData['Name']\n ),\n 'industry' => $industry,\n 'domain' => $domain,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'ext' => $parsedNumber['ext'] ?? null,\n 'country_code' => $countryCode,\n 'remotely_created_at' => $createdDate,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->accounts(), $crmData['Id']);\n\n /** @var Account $account */\n $account = $this->config->accounts()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n $this->handleObjectDeletion($account, $crmData);\n\n return $account;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOpportunities(array $parameters, ?string $strategy = null): int\n {\n $strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);\n\n $syncCount = 0;\n $logParams = $parameters;\n $parameters['profile'] = $this->profile;\n $logParams['user'] = $this->profile->getUserId();\n\n if (count($strategies) > 1) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Multiple sync strategies used', [\n 'teamId' => $this->team->getUuid(),\n 'params' => $logParams,\n 'strategies_count' => count($strategies),\n ]);\n }\n\n foreach ($strategies as $syncStrategy) {\n $name = $syncStrategy->getStrategyName();\n\n try {\n $sfOpportunities = $syncStrategy->fetchOpportunities($parameters);\n $totalRecords = $sfOpportunities->count();\n\n foreach ($sfOpportunities as $sfOpportunity) {\n $this->importOpportunity($sfOpportunity);\n $syncCount++;\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n $this->logger->warning('[' . $this->getDisplayName() . '] No opportunities found', [\n 'teamId' => $this->team->getUuid(),\n 'name' => $name,\n 'params' => $logParams,\n 'reason' => $noResultsException->getMessage(),\n ]);\n } catch (CrmException $crmException) {\n // Nothing to sync.\n $this->logger->warning('[' . $this->getDisplayName() . '] Opportunity sync failed', [\n 'teamId' => $this->team->getUuid(),\n 'name' => $name,\n 'params' => $logParams,\n 'reason' => $crmException->getMessage(),\n ]);\n }\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::OPPORTUNITY, ['params' => $logParams]);\n\n // debug to see how if count of opportunities reaches 1000\n if ($syncCount >= 1000) {\n $this->logger->info(\n '[' . $this->getDisplayName() . '] Sync Opportunities - count warning',\n [\n 'team_id' => $this->team->getId(),\n 'params' => $logParams,\n 'count' => $syncCount,\n 'strategies_count' => count($strategies),\n 'total_records' => $totalRecords ?? null,\n ]\n );\n }\n\n return $syncCount;\n }\n\n\n /**\n * @inheritdoc\n */\n public function syncOpportunity(string $crmId): ?Opportunity\n {\n $strategy = $this->opportunitySyncStrategyResolver->resolve(\n $this->config,\n OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY\n );\n\n $parameters = [\n 'profile' => $this->profile,\n 'crm_id' => $crmId,\n ];\n\n try {\n $sfOpportunity = $strategy->fetchOpportunities($parameters);\n } catch (HttpNotFoundException $e) {\n $this->logger->info('[' . $this->getDisplayName() . '] Opportunity not found', [\n 'teamId' => $this->team->id_string,\n 'crmId' => $crmId,\n ]);\n\n return null;\n } catch (CrmException $crmException) {\n $this->logger->info('[' . $this->getDisplayName() . '] Opportunity sync failed', [\n 'teamId' => $this->team->id_string,\n 'crmId' => $crmId,\n 'exception' => $crmException->getMessage(),\n ]);\n\n return null;\n }\n\n if ($sfOpportunity instanceof ArrayIterator) {\n return $this->importOpportunity($sfOpportunity->getItems());\n }\n\n return $this->importOpportunity($sfOpportunity);\n }\n\n /**\n * @throws HttpNotFoundException\n */\n private function importOpportunity($crmData): ?Opportunity\n {\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $account = null;\n if (empty($crmData['AccountId']) === false) {\n /** @var ?Account $account */\n $account = $this->config->accounts()\n ->where('crm_provider_id', (string) $crmData['AccountId'])\n ->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmData['AccountId']);\n }\n }\n\n $userId = $profile?->getUserId() ?? $account?->getUserId();\n if ($userId === null) {\n $this->logger->error('[Salesforce] | Skip import, no user_id found', [\n 'id' => $crmData['Id'],\n ]);\n\n return null;\n }\n\n /** @var ?Stage $stage */\n $stage = null;\n if (isset($crmData['StageName'])) {\n $stage = $this->config\n ->stages()\n ->where('name', $crmData['StageName'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->orderBy('is_selectable', 'DESC')\n ->orderBy('id')\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $crmData['StageName']);\n }\n }\n\n $recordType = null;\n if (empty($crmData['RecordTypeId']) === false) {\n /** @var ?RecordType $recordType */\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $crmData['RecordTypeId'])\n ->first();\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $closeDate = null;\n if (empty($crmData['CloseDate']) === false) {\n $closeDate = Carbon::parse($crmData['CloseDate'])->format('Y-m-d');\n }\n\n $valueFieldName = 'Amount';\n if ($this->config->opportunity_value_field_id) {\n $valueFieldName = $this->config->opportunityValueField->crm_provider_id;\n }\n\n $data = [\n 'team_id' => $this->team->id,\n 'account_id' => $account->id ?? null,\n 'user_id' => $userId,\n 'owner_id' => $crmData['OwnerId'] ?? null,\n 'name' => mb_strimwidth($crmData['Name'] ?? '', 0, 128),\n 'value' => $crmData[$valueFieldName],\n 'currency_code' => CurrencyFormatter::formatCode($crmData['CurrencyIsoCode'] ?? null),\n 'close_date' => $closeDate,\n 'is_closed' => $crmData['IsClosed'],\n 'is_won' => $crmData['IsWon'],\n 'stage_id' => $stage?->id ?? null,\n 'record_type_id' => $recordType->id ?? null,\n 'remotely_created_at' => $createdDate,\n 'probability' => $crmData['Probability'] ?? null,\n 'forecast_category' => $crmData['ForecastCategoryName'] ?? null,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->opportunities(), $crmData['Id']);\n\n // Do not allow locked DB tables & other errors\n // to interrupt the process of reverting the trashed opportunities\n try {\n /** @var Opportunity $opportunity */\n $opportunity = $this->config->opportunities()\n ->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n // import external fields into crm_field_data if present\n $crmFields = $this->getOpportunitySyncableFields();\n\n $this->importOpportunityCrmFieldData($crmData, $crmFields, $opportunity->id);\n\n $this->handleObjectDeletion($opportunity, $crmData);\n\n if ($opportunity->trashed()) {\n return null;\n }\n\n if ($opportunity->wasRecentlyCreated) {\n MatchActivitiesToNewOpportunity::dispatch($opportunity->getId());\n }\n\n return $opportunity;\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n $this->logger->error('[Salesforce] importOpportunity failure.', [\n 'crm_provider_id' => $crmData['Id'],\n 'team_id' => $this->team->id,\n 'exception' => $exception->getMessage(),\n ]);\n\n $this->handleEntityDeletionByProviderId($this->config->opportunities(), $crmData);\n }\n\n return null;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContacts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n $fields = $this->getAllFieldsAsArray('contact');\n if (\\in_array('Id', $fields, true) === false) {\n return $syncCount;\n }\n\n $query = '\n SELECT ' . rtrim(implode(',', $fields), ',') . '\n FROM Contact\n WHERE LastModifiedDate > :since\n ORDER BY LastModifiedDate ASC';\n\n try {\n $sfContacts = $this->queryHandler->query($query, [\n 'since' => $since->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($sfContacts as $sfContact) {\n // Only sync if previously imported.\n if ($this->hasContact($sfContact['Id'])) {\n $this->importContact($sfContact);\n $syncCount++;\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::CONTACT);\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContact(string $crmId): ?Contact\n {\n $fields = $this->getAllFieldsAsArray('contact');\n if (! in_array('Id', $fields, true)) {\n $this->logger->info('[Salesforce] Sync contact cancelled. Fields are not available.', [\n 'crmId' => $crmId,\n 'userId' => $this->profile->getUserId(),\n ]);\n\n return null;\n }\n\n $sfContact = $this->getRecord('Contact', $crmId, $fields);\n\n return $this->importContact($sfContact);\n }\n\n private function importContact($crmData): ?Contact\n {\n $account = null;\n // Contacts may not have accounts...\n if (isset($crmData['AccountId'])) {\n $account = $this->config->accounts()\n ->where('crm_provider_id', (string) $crmData['AccountId'])\n ->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmData['AccountId']);\n }\n }\n\n $countryCode = $crmData['MailingCountryCode'] ?? null;\n\n // Salesforce allows custom \"countries\" to be created. Disregard these.\n if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {\n $countryCode = null;\n }\n\n // If we have no country code, try to parse it from the country name.\n if ($countryCode === null && empty($crmData['MailingCountry']) === false) {\n $countryCode = $this->convertCountryNameToCode($crmData['MailingCountry']);\n\n if ($countryCode === null && $account) {\n $countryCode = $account->country_code;\n }\n }\n\n $ext = null;\n $parsedNumber = [];\n if (empty($crmData['Phone']) === false) {\n $number = Str::limit($crmData['Phone'], 25, '');\n $parsedNumber = parsePhoneNumber($countryCode, $number);\n\n if (empty($parsedNumber['ext']) === false) {\n $ext = Str::limit($parsedNumber['ext'], 10, '');\n }\n }\n\n $mobileNumber = null;\n if (empty($crmData['MobilePhone']) === false) {\n $mobileNumber = Str::limit(phone_e164($countryCode, $crmData['MobilePhone']), 25, '');\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $data = [\n 'team_id' => $this->team->id,\n 'account_id' => $account->id ?? null,\n 'user_id' => $profile?->user_id,\n 'owner_id' => $crmData['OwnerId'] ?? null,\n 'name' => ($crmData['Name'] ?? null) !== null ? mb_strimwidth($crmData['Name'], 0, 100) : '',\n 'title' => ($crmData['Title'] ?? null) !== null ? mb_strimwidth($crmData['Title'], 0, 128) : null,\n 'email' => ($crmData['Email'] ?? null) !== null ? mb_strimwidth($crmData['Email'], 0, 191) : null,\n 'country_code' => $countryCode,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'ext' => $ext,\n 'mobile_phone' => $mobileNumber,\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['Id'],\n modelType: Contact::class,\n fileName: $crmData['Id'],\n avatarText: $crmData['Name']\n ),\n 'remotely_created_at' => $createdDate,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->contacts(), $crmData['Id']);\n\n /** @var Contact $contact */\n $contact = $this->config->contacts()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n $this->handleObjectDeletion($contact, $crmData);\n\n if ($contact->trashed()) {\n return null;\n }\n\n return $contact;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n $fields = [\n 'InstanceName',\n 'OrganizationType',\n 'IsSandbox',\n ];\n\n $orgValues = $this->getRecord('Organization', $this->config->crm_provider_id, $fields);\n\n $edition = null;\n switch ($orgValues['OrganizationType']) {\n case 'Developer Edition':\n $edition = Configuration::EDITION_DEVELOPER;\n\n break;\n\n case 'Professional Edition':\n $edition = Configuration::EDITION_PROFESSIONAL;\n\n break;\n\n case 'Enterprise Edition':\n $edition = Configuration::EDITION_ENTERPRISE;\n\n break;\n }\n\n $this->config->edition = $edition;\n $this->config->instance = $orgValues['InstanceName'];\n\n // XXX: How can this state be possible?\n if ($this->config->version === null) {\n $this->config->version = Client::MIN_API_VERSION;\n }\n\n $installedVersion = $this->getInstalledAppVersion();\n if ($installedVersion !== null) {\n $installedVersion = (string) $this->getInstalledAppVersion();\n }\n\n $this->config->installed_app_version = $installedVersion;\n\n $this->config->save();\n }\n\n public function getInstalledAppVersion(): ?string\n {\n try {\n $query = '\n SELECT\n SubscriberPackageVersion.MajorVersion,\n SubscriberPackageVersion.MinorVersion,\n SubscriberPackageVersion.PatchVersion,\n SubscriberPackageVersion.BuildNumber\n FROM\n InstalledSubscriberPackage\n WHERE\n SubscriberPackageId = :packageId\n ';\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'packageId' => self::INSTALLED_PACKAGE_ID,\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n // Grab version number.\n $version = $sfField['SubscriberPackageVersion']['MajorVersion'] .\n $sfField['SubscriberPackageVersion']['MinorVersion'] .\n $sfField['SubscriberPackageVersion']['PatchVersion'] .\n $sfField['SubscriberPackageVersion']['BuildNumber'];\n } catch (\\Exception) {\n $version = null;\n }\n\n return $version;\n }\n\n /**\n * Store transcripts as note.\n *\n * @throws \\Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // For SF we also check if Log Notes is enabled.\n if ($this->profile->log_notes === Profile::LOG_NOTE_NONE) {\n return;\n }\n\n if ($activity->opportunity_id && $activity->prospect === null) {\n return;\n }\n\n try {\n $transcriptionData = $this->generateTranscription($activity);\n\n $noteMaxLength = $this->profile->log_notes === Profile::LOG_NOTE_ENHANCED\n ? self::ENHANCED_NOTE_MAX_LENGTH\n : self::CLASSIC_NOTE_MAX_LENGTH;\n\n $title = 'Transcript for ';\n $title .= $activity->title ?? $activity->activity_title;\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $body = mb_strimwidth($transcriptionData, 0, $noteMaxLength);\n\n if ($activity->opportunity_id) {\n $objectId = $activity->opportunity->crm_provider_id;\n } else {\n $objectId = $activity->prospect->crm_provider_id;\n }\n\n $noteId = $this->saveNote($title, $body, $objectId);\n\n // Store crm logged id in transcription.\n $transcription = $activity->getTranscription();\n $transcription->crm_activity_id = $noteId;\n $transcription->save();\n } catch (\\Exception $e) {\n \\Sentry::captureException($e);\n }\n }\n\n public function saveNote(string $title, string $body, string $objectId, ?NoteObject $noteObject = null): ?string\n {\n $noteId = null;\n\n try {\n if ($this->profile->log_notes === Profile::LOG_NOTE_ENHANCED) {\n $noteId = $this->buildEnhancedNote($title, $body, $objectId);\n } else {\n $noteId = $this->buildClassicNote($title, $body, $objectId);\n }\n } catch (HttpNotFoundException $exception) {\n // The profile not having access to create Enhanced Notes. Set their preference to Classic.\n if ($this->profile->log_notes === Profile::LOG_NOTE_ENHANCED) {\n $this->profile->update([\n 'log_notes' => Profile::LOG_NOTE_CLASSIC,\n ]);\n }\n }\n\n return $noteId;\n }\n\n /**\n * This is using the \"Enhanced\" Notes feature, NOT the \"Notes & Attachments\" feature being deprecated.\n *\n * @url https://salesforce.stackexchange.com/questions/104408/how-can-i-create-an-account-note-or-contact-note-via-api-that-is-visible-in-sale\n */\n private function buildEnhancedNote(string $title, string $body, string $objectId): string\n {\n // Decode stored entities, escape HTML (without quoting), then convert line breaks for Salesforce formatting\n $decodedBody = html_entity_decode($body, ENT_QUOTES | ENT_HTML5);\n $sanitizedBody = htmlspecialchars($decodedBody, ENT_NOQUOTES, 'UTF-8', false);\n $content = nl2br($sanitizedBody, false);\n $note = [\n 'OwnerId' => $this->profile->crm_provider_id,\n 'Title' => $title,\n 'Content' => base64_encode($content),\n ];\n\n $noteId = $this->createRecord('ContentNote', $note);\n\n $link = [\n 'ContentDocumentId' => $noteId,\n 'LinkedEntityId' => $objectId,\n 'ShareType' => 'I',\n ];\n\n $this->createRecord('ContentDocumentLink', $link);\n\n return $noteId;\n }\n\n private function buildClassicNote(string $title, string $body, string $objectId): string\n {\n if (in_array($this->parseObjectType($objectId), [Field::OBJECT_TASK, Field::OBJECT_EVENT])) {\n $this->logger->info('[Salesforce] Summary not sent', [\n 'profile_id' => $this->profile->id,\n 'objectId' => $objectId,\n 'reason' => 'Classical Note does not support Task/Event relation',\n ]);\n\n return '';\n }\n\n $titleTrimmed = null;\n\n if (mb_strlen($title) > 80) {\n $titleTrimmed = substr($title, 0, 77) . '...';\n }\n $payload = [\n 'OwnerId' => $this->profile->crm_provider_id,\n 'IsPrivate' => false,\n 'Title' => $titleTrimmed ?? $title,\n 'Body' => $titleTrimmed ? $title . PHP_EOL . $body : $body,\n 'ParentId' => $objectId,\n ];\n\n return $this->createRecord('Note', $payload);\n }\n\n /**\n * @inheritdoc\n */\n public function find(string $name, array $scopes): array\n {\n if ($this->profile === null) {\n return [];\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $limitValues = ['limit' => $this->limit, 'offset' => $this->offset];\n $sosl = $queryBuilder->buildFindQuery($name, $scopes, $limitValues);\n\n $this->logger->info('[Salesforce] Find prospects', [\n 'profile_id' => $this->profile->id,\n 'sosl_query' => $sosl,\n 'search_string' => $name,\n 'scopes' => $scopes,\n ]);\n\n $data = Cache::remember($this->profile->id . $sosl, self::CACHE_TTL, function () use ($sosl) {\n $data = [];\n\n try {\n // Hit remote API.\n $objects = $this->queryHandler->search($sosl);\n\n // Build mapped list.\n foreach ($objects as $object) {\n $type = strtolower($object['attributes']['type']);\n\n $record = [\n 'crmId' => $object['Id'],\n 'name' => $object['Name'],\n 'prospectType' => $type,\n 'phoneNumbers' => [],\n 'crmUrl' => $this->generateProviderUrl($object['Id'], $type),\n ];\n\n switch ($type) {\n case 'lead':\n if (empty($object['Company']) === false) {\n $record['organization'] = $object['Company'];\n }\n\n if (empty($object['Title']) === false) {\n $record['title'] = $object['Title'];\n }\n\n $stage = $this->config->stages()\n ->where('type', Stage::TYPE_LEAD)\n ->where('name', $object['Status'])\n ->first();\n\n // Lazy create the stage.\n if ($stage === null) {\n $stage = $this->importStages([Stage::TYPE_LEAD], $object['Status']);\n }\n\n if ($stage) {\n $record += [\n 'stage' => [\n 'id' => $stage->id_string,\n 'name' => $stage->name,\n ],\n ];\n }\n\n if (empty($object['RecordTypeId']) === false) {\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $object['RecordTypeId'])\n ->first();\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n }\n\n break;\n\n case 'account':\n if (empty($object['Industry']) === false) {\n $record['industry'] = $object['Industry'];\n $record['detailsLine'] = $object['Industry'];\n }\n if (! empty($object['PersonEmail'])) {\n $record['detailsLine'] = $object['PersonEmail'];\n }\n\n break;\n\n case 'contact':\n // For contacts, we should try and fetch their account name too.\n if ($object['AccountId']) {\n // Cheaper to get this locally.\n $account = $this->config->accounts()\n ->where('crm_provider_id', $object['AccountId'])\n ->first(['name']);\n\n if ($account) {\n $record['organization'] = $account->name;\n }\n }\n\n if (! empty($object['IsPersonAccount']) && $object['Email']) {\n $record['detailsLine'] = $object['Email'];\n } else {\n if (empty($object['Title']) === false) {\n $record['title'] = $object['Title'];\n }\n }\n\n break;\n }\n\n // Add phone numbers to record.\n if (empty($object['Phone']) === false && $object['Phone']) {\n $record['phoneNumbers'][] = [\n 'number' => $object['Phone'],\n 'nationalFormat' => phone_national($this->profile->user->country_code, $object['Phone']),\n 'type' => 'phone',\n ];\n }\n\n if (empty($object['MobilePhone']) === false && $object['MobilePhone']) {\n $record['phoneNumbers'][] = [\n 'number' => $object['MobilePhone'],\n 'nationalFormat' => phone_national(\n $this->profile->user->country_code,\n $object['MobilePhone']\n ),\n 'type' => 'mobile',\n ];\n }\n\n $data[] = $record;\n }\n } catch (NoResultsException $e) {\n $data = [];\n }\n\n return $data;\n });\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n\n if ($crmAccountId === null) {\n return $data;\n }\n\n if ($userId) {\n $profileRepository = app(ProfileRepository::class);\n $profile = $profileRepository->findProfileByUserId($this->config, $userId);\n\n $ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;\n }\n\n try {\n // Perhaps their profile has no opportunity permissions.\n if ($this->profile === null || $this->profile->opportunity_fields === null) {\n return $data;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $query = $queryBuilder->buildFindOpportunitiesQuery();\n\n $objects = $this->queryHandler->query($query, ['accountId' => $crmAccountId]);\n\n foreach ($objects as $object) {\n $record = [\n 'crmId' => $object['Id'],\n 'name' => $object['Name'],\n 'won' => $object['IsWon'],\n 'closed' => $object['IsClosed'],\n ];\n\n $valueFieldName = 'Amount';\n if ($this->config->opportunity_value_field_id) {\n $valueFieldName = $this->config->opportunityValueField->crm_provider_id;\n }\n\n if (empty($object[$valueFieldName]) === false) {\n $currency = $object['CurrencyIsoCode'] ?? $this->config->default_currency;\n $value = formatCurrency($object[$valueFieldName], $currency);\n\n $record += [\n 'value' => $value,\n ];\n }\n\n $stage = $this->config->stages()\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->where('name', $object['StageName'])\n ->first();\n\n // Lazy create the stage.\n if ($stage === null) {\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $object['StageName']);\n }\n\n $record += [\n 'stage' => [\n 'id' => $stage->id_string,\n 'name' => $stage->name,\n ],\n ];\n\n if (empty($object['RecordTypeId']) === false) {\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $object['RecordTypeId'])\n ->first();\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n }\n\n if ($ownerId && isset($object['OwnerId']) && $object['OwnerId'] === $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n } catch (NoResultsException $e) {\n return $data;\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n public function getContactRolesFromCrm(?Carbon $since = null): array\n {\n $roles = [];\n\n if ($this->profile === null) {\n return $roles;\n }\n\n $queryBuilder = app(QueryBuilder::class, ['profile' => $this->profile]);\n\n $query = $queryBuilder->buildGetContactRolesQuery($since);\n\n try {\n $objects = $this->queryHandler->query($query);\n\n foreach ($objects as $object) {\n $roles[] = [\n 'id' => $object['Id'],\n 'contactId' => $object['ContactId'],\n 'opportunityId' => $object['OpportunityId'],\n 'ownerId' => $object['Opportunity']['OwnerId'] ?? null,\n 'isPrimary' => $object['IsPrimary'],\n 'role' => $object['Role'],\n ];\n }\n } catch (NoResultsException) {\n // Just return an empty array.\n $this->logger->info('[Salesforce] No contact roles found', [\n 'since' => $since?->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n }\n\n return $roles;\n }\n\n public function syncContactRoles(Carbon $since): int\n {\n $contactRoleRepository = app(ContactRoleRepository::class);\n\n $crmContactRoles = $this->getContactRolesFromCrm(since: $since);\n $syncCount = 0;\n $contactRoles = [];\n\n foreach ($crmContactRoles as $crmContactRole) {\n $contactRoles[] = $this->importContactRole($crmContactRole);\n $syncCount++;\n }\n\n $contactRoleRepository->saveContactRoles($contactRoles);\n\n $this->syncRemotelyDeletedContactRoles();\n\n return $syncCount;\n }\n\n private function importContactRole(array $contactRole): array\n {\n $contact = $this->config->contacts()\n ->where('crm_provider_id', $contactRole['contactId'])\n ->first();\n\n if ($contact === null) {\n $contact = $this->syncContact($contactRole['contactId']);\n }\n\n $opportunity = $this->config->opportunities()\n ->where('crm_provider_id', $contactRole['opportunityId'])\n ->first();\n\n if ($opportunity === null) {\n $opportunity = $this->syncOpportunity($contactRole['opportunityId']);\n }\n\n $role = null;\n if (! empty($contactRole['role'])) {\n $role = mb_strimwidth($contactRole['role'], 0, 191);\n }\n\n return [\n 'crm_configuration_id' => $this->config->getId(),\n 'contact_id' => $contact->getId(),\n 'crm_provider_id' => $contactRole['id'],\n 'subject_type' => ContactRole::SUBJECT_TYPE_OPPORTUNITY,\n 'subject_id' => $opportunity->getId(),\n 'is_primary' => $contactRole['isPrimary'],\n 'role' => $role,\n ];\n }\n\n protected function syncRemotelyDeletedContactRoles(): bool\n {\n try {\n $deletedRemotely = $this->queryHandler->queryDeleted('OpportunityContactRole');\n } catch (NoResultsException $e) {\n return false;\n }\n\n $deletedOpportunities = $deletedRemotely->getResults();\n $deletedIds = array_column($deletedOpportunities, 'id');\n\n $contactRoleRepository = app(ContactRoleRepository::class);\n\n foreach (array_chunk($deletedIds, self::HARD_DELETE_CHUNK) as $chunk) {\n $contactRoleRepository->deleteContactRoles($chunk);\n\n $this->logger->info('[' . $this->getDisplayName() . '] Remotely deleted opportunities synced', [\n 'teamId' => $this->team->id_string,\n 'remotelyDeletedOpportunities' => $chunk,\n 'count' => count($chunk),\n ]);\n }\n\n return true;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = $objects = [];\n\n $hasWho = \\in_array($objectType, ['lead', 'contact']);\n $playbook = $this->getPlaybook($this->profile->user);\n $fields = array_merge(\n $this->profile->getFieldsAsArray(parent::OBJECT_TASK),\n $playbook && $playbook->activityField ? [$playbook->activityField->crm_provider_id] : []\n );\n\n // Query should default to any open call for that user.\n $query = '\n SELECT ' . implode(',', array_unique($fields)) . '\n FROM Task\n WHERE OwnerId = :ownerId\n AND IsArchived = false\n AND IsDeleted = false\n AND IsClosed = false\n AND (';\n\n if ($objectType === 'account') {\n // This covers tasks tied to a related contact or opportunity too.\n $query .= '\n AccountId = :accountId';\n }\n\n if ($hasWho) {\n $query .= '\n WhoId = :whoId';\n\n // If we are also going to check on a specific opportunity, set that up.\n if ($opportunityId) {\n $query .= ' OR WhatId = :whatId';\n }\n }\n\n $query .= ' ) ORDER BY LastModifiedDate DESC';\n\n try {\n $objects = $this->queryHandler->query($query, [\n 'ownerId' => $this->profile->crm_provider_id,\n 'whoId' => $objectId,\n 'whatId' => $opportunityId,\n 'accountId' => $objectId,\n ]);\n } catch (NoResultsException $e) {\n return $data;\n } finally {\n $this->logger->debug(sprintf('[Salesforce] Found %s tasks for query \"%s\"', count($objects), $query));\n }\n\n foreach ($objects as $object) {\n $dueDate = $object['ActivityDate'] ? Carbon::parse($object['ActivityDate'])->toIso8601String() : null;\n $data[] = [\n 'crmId' => $object['Id'],\n 'subject' => $object['Subject'],\n 'due' => $dueDate,\n 'type' => $object[$playbook->activityField->crm_provider_id],\n ];\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getEvents(string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = $objects = [];\n $user = $this->profile?->user;\n if ($this->profile === null || $user === null) {\n return $data;\n }\n\n $hasWho = \\in_array($objectType, ['lead', 'contact']);\n $playbook = $this->getPlaybook($user);\n $fields = array_merge(\n $this->profile->getFieldsAsArray(parent::OBJECT_EVENT),\n $playbook && $playbook->activityField ? [$playbook->activityField->crm_provider_id] : []\n );\n\n // Query should default to any event starting in the last week and ending up until today owned by the user.\n $query = '\n SELECT ' . implode(',', array_unique($fields)) . '\n FROM Event\n WHERE OwnerId = :ownerId\n AND IsArchived = false\n AND IsAllDayEvent = false\n AND StartDateTime >= LAST_N_DAYS:7\n AND EndDateTime <= TODAY\n AND (';\n\n if ($objectType === 'account') {\n // This covers events tied to a related contact or opportunity too.\n $query .= '\n AccountId = :accountId';\n }\n\n if ($hasWho) {\n $query .= '\n WhoId = :whoId';\n\n // If we are also going to check on a specific opportunity, set that up.\n if ($opportunityId) {\n $query .= ' OR WhatId = :whatId';\n }\n }\n\n $query .= ' ) ORDER BY LastModifiedDate DESC';\n\n try {\n $objects = $this->queryHandler->query($query, [\n 'ownerId' => $this->profile->crm_provider_id,\n 'whoId' => $objectId,\n 'whatId' => $opportunityId,\n 'accountId' => $objectId,\n ]);\n } catch (NoResultsException $e) {\n return $data;\n } finally {\n $this->logger->debug(sprintf('[Salesforce] Found %s tasks for query \"%s\"', count($objects), $query));\n }\n\n foreach ($objects as $object) {\n $dueDate = $object['StartDateTime'] ? Carbon::parse($object['StartDateTime'])->toIso8601String() : null;\n\n $data[] = [\n 'crmId' => $object['Id'],\n 'subject' => $object['Subject'],\n 'due' => $dueDate,\n 'type' => $object[$playbook->activityField->crm_provider_id],\n ];\n }\n\n return $data;\n }\n\n /**\n * Try to find CRM Objects using email address\n *\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchExactlyByEmail(string $email, ?int $userId = null): ?array\n {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $sosl = $queryBuilder->buildMatchByQuery($email, Field::TYPE_EMAIL);\n if ($sosl === null) {\n return null;\n }\n\n try {\n $objects = $this->queryHandler->search($sosl);\n $objects = $this->queryHandler->prioritiseResults(\n $objects,\n $email,\n QueryHandler::PRIORITISE_EMAIL\n );\n\n $data = $this->convertCrmData($objects, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (NoResultsException $e) {\n // Try the account next.\n if ($this->profile->account_fields === null) {\n return null;\n }\n }\n\n return null;\n }\n\n public function getDomain(string $email): ?string\n {\n // SF improved search - strip the domain extension, min domain name length 4\n return $this->getCompanyNameFromEmail(email: $email, minNameLength: 4);\n }\n\n /**\n * Try to find CRM objects using domain name of the email address\n *\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByDomain(string $domain, ?int $userId = null): ?array\n {\n $companyName = $domain;\n\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $sosl = $queryBuilder->buildMatchByDomainQuery($companyName);\n\n try {\n $objects = $this->queryHandler->search($sosl);\n\n $data = $this->convertCrmData($objects, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (NoResultsException) {\n return null;\n }\n }\n\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n // Don't bother looking up numbers that are masked.\n if (str_contains($phone, '**')) {\n return null;\n }\n\n if ($this->isPhoneNumberOfTeamMember($phone)) {\n return null;\n }\n\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $phoneNational = phone_national(null, $phone) ?? '';\n $possiblePhoneFormats = collect([\n preg_replace('/\\D/', '', ltrim($phone, '0+')),\n preg_replace('/\\D/', '', $phoneNational),\n formatDashPhoneNumber($phone),\n $phoneNational,\n ])\n ->filter() // Removes null and empty strings\n ->unique()\n ->values();\n\n foreach ($possiblePhoneFormats as $phone) {\n $sosl = $queryBuilder->buildMatchByQuery($phone, Field::TYPE_PHONE);\n if ($sosl === null) {\n continue;\n }\n\n try {\n $objects = $this->queryHandler->search($sosl);\n $objects = $this->queryHandler->prioritiseResults(\n $objects,\n $phone,\n QueryHandler::PRIORITISE_PHONE\n );\n\n return $this->convertCrmData($objects, $userId);\n } catch (NoResultsException) {\n continue;\n }\n }\n\n return null;\n }\n\n private function isPhoneNumberOfTeamMember(string $phone): bool\n {\n $teamRepository = app(TeamRepository::class);\n $user = $teamRepository->findTeamMemberByPhone($this->team, $phone);\n\n if ($user instanceof User) {\n return true;\n }\n\n return false;\n }\n\n protected function getCacheKey(string $object, ?int $userId = null): ?string\n {\n $key = $this->profile->id . $object;\n $keySuffix = $this->getOwnerKeySuffix($userId);\n\n return $key . $keySuffix;\n }\n\n private function getOwnerKeySuffix(?int $userId = null): string\n {\n return $userId === null ? '' : (string) $userId;\n }\n\n /** Determine the CRM Objects which represent the call activity. */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n if ($this->profile === null) {\n return null;\n }\n\n $cacheKey = $this->getCacheKey($name, $userId);\n\n $result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $sosl = $queryBuilder->buildMatchByQuery($name, 'name');\n if ($sosl === null) {\n return false;\n }\n\n try {\n $objects = $this->queryHandler->search($sosl);\n } catch (NoResultsException $e) {\n return false;\n }\n\n $objects = $this->queryHandler->prioritiseResults(\n $objects,\n $name,\n QueryHandler::PRIORITISE_NAME\n );\n\n $data = $this->convertCrmData($objects, $userId);\n\n return (! empty(array_filter($data))) ? $data : false;\n });\n\n return is_array($result) ? $result : null;\n }\n\n /**\n * @return array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n protected function convertCrmData(QueryIterator $objects, ?int $userId = null): array\n {\n $lead = null;\n $contact = null;\n $opportunity = null;\n $account = null;\n $stage = null;\n $countryCode = null;\n\n if ($objects->count() > 0) {\n $object = $objects->current();\n\n if ($object['attributes']['type'] === 'Lead') {\n $lead = $this->importLead($object);\n\n // Lead might not be imported if the Stage is null for example.\n if ($lead) {\n $countryCode = $lead->country_code;\n $stage = $lead->stage;\n }\n } else {\n if ($object['attributes']['type'] === 'Contact') {\n $contact = $this->importContact($object);\n $account = $contact->account;\n } else {\n $account = $this->importAccount($object);\n }\n\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account) {\n $countryCode = $account->country_code;\n }\n\n try {\n $sfOpportunities = $this->findOpportunities(\n $account?->getCrmProviderId(),\n $contact?->getCrmProviderId(),\n $userId\n );\n\n // Take the first opportunity, which will be ordered as priority based on their settings.\n if (! empty($sfOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($sfOpportunities[0]['crmId']);\n $stage = $opportunity?->stage;\n }\n } catch (Exception) {\n // Nothing to see here.\n }\n }\n }\n\n return [\n $lead,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n /**\n * @inheritdoc\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n if ($stage->type === Stage::TYPE_LEAD) {\n $objectType = 'Lead';\n $objectId = $crmObject->crm_provider_id;\n $objectStageType = 'Status';\n } else {\n $objectType = 'Opportunity';\n $objectId = $crmObject->crm_provider_id;\n $objectStageType = 'StageName';\n }\n\n $headers = [];\n if ($this->config->trigger_assignment_rules === false) {\n // @see: https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/headers_autoassign.htm\n $headers = [\n 'Sforce-Auto-Assign' => 'false',\n ];\n }\n\n $this->updateRecord($objectType, $objectId, [$objectStageType => $stage->name], $headers);\n }\n\n public function parseObjectType(string $objectId): string\n {\n if (Str::startsWith($objectId, '001')) {\n return 'account';\n }\n\n if (Str::startsWith($objectId, '003')) {\n return 'contact';\n }\n\n if (Str::startsWith($objectId, '00Q')) {\n return 'lead';\n }\n\n if (Str::startsWith($objectId, '006')) {\n return 'opportunity';\n }\n\n if (Str::startsWith($objectId, '00U')) {\n return 'event';\n }\n\n if (Str::startsWith($objectId, '00T')) {\n return 'task';\n }\n\n throw new \\InvalidArgumentException('Unsupported Object Type');\n }\n\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, ['profile' => $this->profile]);\n $query = $queryBuilder->buildGetUsersQuery($userToSearch);\n\n try {\n $salesforceUsers = $this->queryHandler->query($query, [\n 'active' => true,\n ]);\n } catch (NoResultsException $e) {\n $this->logger->info('[Salesforce] Sync Profiles. No users found', [\n 'query' => $query,\n 'error' => $e->getMessage(),\n ]);\n\n return null;\n }\n\n $teamRepository = app(TeamRepository::class);\n $customRules = $this->getCustomProfileRules($teamRepository);\n\n foreach ($salesforceUsers as $crmUser) {\n if ($crmUser['Email'] === null) {\n continue;\n }\n\n if (! $this->customProfileValidation($crmUser, $customRules)) {\n continue;\n }\n\n $user = $teamRepository->findActiveTeamMemberByEmail($this->team, $crmUser['Email']);\n\n if (! $user instanceof User) {\n continue;\n }\n\n $edition = $crmUser['UserPreferencesLightningExperiencePreferred']\n ? Profile::EDITION_LIGHTNING\n : Profile::EDITION_CLASSIC;\n\n $profileRepository = app(ProfileRepository::class);\n $profile = $profileRepository->updateOrCreateProfile(\n $user,\n [\n 'crm_configuration_id' => $this->config->getId(),\n 'crm_provider_id' => $crmUser['Id'],\n ],\n [\n 'user_id' => $user->getId(),\n 'edition' => $edition,\n 'has_external_cti' => ! empty($crmUser['CallCenterId']),\n 'crm_profile_id' => $crmUser['ProfileId'],\n ]\n );\n\n if ($userToSearch instanceof User && $userToSearch->getId() === $user->getId()) {\n return $profile;\n }\n }\n\n // Clean up inactive profiles\n try {\n $this->archiveInactiveProfiles();\n } catch (\\Exception $e) {\n $this->logger->warning('[Salesforce] Profile archiving failed', [\n 'teamId' => $this->team->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $url = null;\n\n // For Salesforce it's easy, we just point every object to the apex domain and they handle it.\n switch ($objectType) {\n case 'lead':\n case 'account':\n case 'contact':\n case 'opportunity':\n case 'task':\n case 'event':\n case 'activity':\n\n $url = $this->config->crm_base_url . '/' . $providerId;\n\n break;\n }\n\n return $url;\n }\n\n public function buildTaskSearchFields(): array\n {\n return ['Id', 'WhoId', 'WhatId', 'AccountId'];\n }\n\n public function getTaskByFilterConditions(\n array $fields,\n array $filters,\n bool $bulkSearch = false,\n bool $strictFilters = true\n ): ?array {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $query = $queryBuilder->buildSearchTaskQuery($fields, $filters, $bulkSearch, $strictFilters);\n\n try {\n if (! $bulkSearch) {\n $objects = $this->queryHandler->query($query, $filters);\n if ($objects->count() === 1) {\n return $objects->current();\n }\n }\n\n if ($bulkSearch) {\n $objects = $this->queryHandler->query($query);\n $records = [];\n foreach ($objects as $record) {\n $key = $record[end($fields)];\n $records[$key] = $record;\n }\n\n return $records;\n }\n } catch (\\Exception $e) {\n $this->logger->info('[Salesforce] Failed to execute query', [\n 'query' => $query,\n 'error' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function mapCrmObjects(array $task): array\n {\n $activityData = [];\n\n if (! empty($task['WhoId'])) {\n $type = $this->parseObjectType($task['WhoId']);\n $activityData[$type] = $task['WhoId'];\n }\n if (! empty($task['AccountId'])) {\n $activityData['account'] = $task['AccountId'];\n }\n if (! empty($task['WhatId'])) {\n $activityData['opportunity'] = $task['WhatId'];\n }\n\n return $activityData;\n }\n\n /**\n * Get SF task by Outreach call id.\n */\n public function getTaskByFilter(\n string $activityFieldType,\n array $filters,\n string $operator = '=',\n array $additionalFields = []\n ): ?array {\n $data = [];\n\n try {\n // Default (base) fields.\n $fields = ['Id', 'Subject', 'Description', 'ActivityDate', 'WhoId', 'WhatId', $activityFieldType];\n\n foreach ($additionalFields as $additionalField) {\n $fields[] = $additionalField->crm_provider_id;\n }\n\n $fields = array_unique($fields);\n\n // Find task with the same Outreach id as the call id.\n $query = 'SELECT ' . implode(',', $fields) . '\n FROM Task\n WHERE IsArchived = false AND IsDeleted = false';\n\n foreach ($filters as $key => $value) {\n $key = preg_quote($key, '/');\n $key = str_replace(['\\'', '\"'], '', $key);\n // Prepare the substitution.\n $strKey = \":$key\";\n\n $query .= \" AND $key $operator $strKey\";\n }\n\n $query .= ' ORDER BY LastModifiedDate DESC LIMIT 1';\n\n $objects = $this->queryHandler->query($query, $filters);\n\n // There should be only one task related to this call if any.\n if ($objects->count() === 1) {\n $object = $objects->current();\n\n $dueDate = $object['ActivityDate'] ? Carbon::parse($object['ActivityDate'])->toIso8601String() : null;\n\n $data = array_merge($object, [\n 'crmId' => $object['Id'],\n 'subject' => $object['Subject'],\n 'summary' => $object['Description'],\n 'due' => $dueDate,\n 'Type' => $object[$activityFieldType],\n ]);\n }\n } catch (NoResultsException $e) {\n // Filters don't match any records.\n } catch (ServiceUnavailableException $serviceUnavailableException) {\n // Service cannot be queried. We should probably log this.\n }\n\n return $data;\n }\n\n /**\n * Get Salesforce fields including datetime fields\n *\n * @param $objectType\n */\n private function getAllFieldsAsArray($objectType): array\n {\n $basicFields = [];\n // Not all users have access to all object fields.\n if ($this->profile->{$objectType . '_fields'}) {\n $basicFields = explode(',', $this->profile->{$objectType . '_fields'});\n }\n\n $extraFields = [\n 'CreatedDate',\n 'LastModifiedDate',\n 'IsDeleted',\n ];\n\n if ($objectType === self::OBJECT_OPPORTUNITY\n && $this->config->opportunity_value_field_id\n && ! in_array($this->config->opportunityValueField->crm_provider_id, $basicFields)\n ) {\n $extraFields[] = $this->config->opportunityValueField->crm_provider_id;\n }\n\n return array_unique(array_merge($basicFields, $extraFields));\n }\n\n /**\n * Generate transcription for activity description.\n */\n private function generateTranscription(Activity $activity): string\n {\n if (! ($this->config->store_transcript)) {\n // If sending transcription to activity toggle is disabled\n return '';\n }\n\n return $this->transcriptionService\n ->findTranscriptionByActivity($activity)\n ->map(static function (array $transcriptionSegment): string {\n return $transcriptionSegment['formattedStartsAt'] . ' | ' . $transcriptionSegment['transcript'];\n })\n ->implode(PHP_EOL);\n }\n\n /**\n * Find related Salesforce event based on activity data\n *\n * @return array<string>\n */\n public function fetchRelatedActivity(Activity $activity): array\n {\n $this->logger->info('[Salesforce] Searching for related activity', [\n 'activityId' => $activity->getUuid(),\n 'ownerId' => $this->profile?->crm_provider_id,\n ]);\n\n $sfEvent = $this->fetchRelatedEvent($activity);\n if (empty($sfEvent)) {\n $this->logger->info('[Salesforce] No related activity found', [\n 'activityId' => $activity->getUuid(),\n 'ownerId' => $this->profile?->crm_provider_id,\n 'account' => $activity->hasAccount()\n ? $activity->getAccount()->getCrmProviderId()\n : null,\n ]);\n\n return [];\n }\n\n return $sfEvent;\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n if ($activity->isTypeConference() === false) {\n return null;\n }\n\n if ($activity->hasActualStartTime() === false && $activity->hasScheduledStartTime() === false) {\n return null;\n }\n\n if (! $activity->hasProspect()) {\n $this->logger->info('[Salesforce] Skip look up, Activity not linked to Lead, Contact or Account', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n $playbook = $this->getPlaybook($activity->getUser());\n if ($playbook !== null && $playbook->getActivityType() === Playbook::ACTIVITY_TYPE_TASK) {\n $this->logger->info('[Salesforce] Skip auto-sync for task-based playbook', [\n 'activityUuid' => $activity->getUuid(),\n 'playbookId' => $playbook->getId(),\n 'playbookType' => $playbook->getActivityType(),\n ]);\n\n return null;\n }\n\n try {\n $sfEvent = $this->fetchRelatedActivity($activity);\n if (empty($sfEvent)) {\n return null;\n }\n\n [$activityField, $activityType] = $this->resolveActivityTypeFromEvent($activity, $sfEvent);\n\n $this->logger->info('[Salesforce] Found related activity', [\n 'activityId' => $activity->getUuid(),\n 'sfEvent' => $sfEvent['Id'],\n 'activityFieldName' => $activityField,\n 'crmActivityType' => ($activityField !== null && isset($sfEvent[$activityField]))\n ? $sfEvent[$activityField]\n : null,\n 'activityType' => $activityType,\n ]);\n\n $userId = $this->findRelatedActivityUserId($activity, $sfEvent);\n\n if ($activity->getUserId() !== $userId) {\n $this->logger->info('[Salesforce] Updating meeting owner', [\n 'activityId' => $activity->getUuid(),\n 'oldUserId' => $activity->getUserId(),\n 'newUserId' => $userId,\n ]);\n }\n\n $this->updateSfEventDescription($activity, $sfEvent);\n\n $activity->update([\n 'user_id' => $userId,\n 'crm_provider_id' => $sfEvent['Id'],\n 'playbook_category_id' => $activityType->id ?? $activity->getCategory()?->getId(),\n ]);\n\n $this->logger->info('[Salesforce] Activity updated', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return $activity;\n } catch (\\Exception $exception) {\n \\Sentry::captureException($exception);\n\n throw $exception;\n }\n }\n\n /**\n * @param array<string, mixed> $sfEvent\n *\n * @return array{0: string|null, 1: mixed}\n */\n private function resolveActivityTypeFromEvent(Activity $activity, array $sfEvent): array\n {\n $activityField = $this->getActivityFieldName($activity);\n $activityType = null;\n\n if ($activityField !== null && ! empty($sfEvent[$activityField])) {\n $playbook = $this->getPlaybook($activity->getUser());\n $activityType = $this->getPlaybookCategory($playbook, strval($sfEvent[$activityField]));\n }\n\n return [$activityField, $activityType];\n }\n\n /**\n * @param array<string> $sfEvent\n */\n private function findRelatedActivityUserId(Activity $activity, array $sfEvent): int\n {\n $userId = $activity->getUserId();\n\n if (empty($sfEvent['OwnerId']) === false) {\n $profile = $this\n ->config\n ->profiles()\n ->where('crm_provider_id', $sfEvent['OwnerId'])\n ->get()\n ->filter(static function (Profile $profile) use ($activity): bool {\n if (! $activity->isTypeConference()) {\n return ! empty($profile->user) ? $profile->user->isStatusActive() : false;\n }\n\n $participants = $activity->getParticipants();\n\n return ! empty($profile->user)\n ? $profile->user->isStatusActive()\n && $profile->user->hasPermission(PermissionEnum::RECORD_MEETING)\n && $participants->contains('user_id', $profile->user_id)\n : false;\n })\n ->first();\n\n if ($profile) {\n $userId = $profile->user_id;\n }\n }\n\n return $userId;\n }\n\n /**\n * @param array<string, mixed> $sfEvent\n */\n private function updateSfEventDescription(Activity $activity, array $sfEvent): void\n {\n try {\n if (str_contains($sfEvent['Description'], $activity->id_string)) {\n return;\n }\n\n $payload = [\n 'Description' => $sfEvent['Description']\n . PHP_EOL\n . PHP_EOL\n . (new DecorateActivity())->generateDescription($activity),\n ];\n\n $this->logger->info('[Salesforce] Update record', [\n 'activityId' => $activity->getUuid(),\n 'sfEvent' => $sfEvent['Id'],\n 'payload' => $payload,\n ]);\n\n $payload = array_merge(\n $payload,\n $this->payloadBuilder->fetchCustomFieldData($activity, Field::OBJECT_EVENT)\n );\n\n $this->updateRecord('Event', $sfEvent['Id'], $payload);\n } catch (\\Exception) {\n $this->logger->error('[Salesforce] Failed to update record', [\n 'activityUuid' => $activity->getUuid(),\n 'sfEvent' => $sfEvent['Id'],\n ]);\n }\n }\n\n /**\n * Returns the most recently modified Event within time range (if any).\n *\n * @return array|null An Event record from Salesforce.\n */\n private function fetchRelatedEvent(Activity $activity): ?array\n {\n $ownerId = $this->profile?->crm_provider_id;\n if ($ownerId === null) {\n return [];\n }\n\n /** @var ?Carbon $from */\n /** @var ?Carbon $to */\n [$from, $to] = $this->getFromToDates($activity);\n\n try {\n $whoId = null;\n $hasWho = $activity->lead_id || $activity->contact_id;\n if ($hasWho) {\n $whoId = $activity->hasLead()\n ? $activity->getLead()->crm_provider_id\n : $activity->getContact()->crm_provider_id;\n }\n\n if ($hasWho === false && $activity->account_id === null) {\n return null;\n }\n\n $query = $this->buildFetchRelatedEventQuery($activity);\n\n $objects = $this->queryHandler->query($query, [\n 'ownerId' => $ownerId,\n 'whoId' => $whoId,\n 'whatId' => $activity->hasOpportunity() ? $activity->getOpportunity()->crm_provider_id : null,\n 'accountId' => $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null,\n 'from' => $from?->format('Y-m-d\\TH:i:s\\Z'),\n 'to' => $to?->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($objects as $object) {\n return $object;\n }\n } catch (NoResultsException $e) {\n return [];\n }\n\n return [];\n }\n\n private function getFromToDates(Activity $activity): array\n {\n $from = null;\n $to = null;\n\n /** @var ?CalendarEvent $calendarEvent */\n $calendarEvent = $activity->calendarEvent()->first();\n if ($calendarEvent !== null) {\n $from = $calendarEvent->getStartTime();\n $to = $calendarEvent->getEndTime();\n }\n\n // For non-calendar imported activities\n // Also double check if calendar event dates could be null?\n // If null use what we've got so far\n if ($from === null || $to === null) {\n $from = $activity->hasScheduledStartTime()\n ? $activity->getScheduledStartTime()\n : $activity->getActualStartTime();\n $to = $activity->hasScheduledEndTime()\n ? $activity->getScheduledEndTime()->addMinutes(15)\n : $activity->getActualEndTime();\n }\n\n return [$from, $to];\n }\n\n /**\n * Determines the appropriate activity field name for querying Salesforce events.\n *\n * This method follows a hierarchy to determine the field name:\n * 1. Uses the playbook's activity field if it exists and is in the profile's accessible fields\n * 2. Falls back to the default activity field if the profile has no event fields configured\n * 3. Returns null if no suitable field is found\n *\n * @param Activity $activity The activity to determine the field for\n *\n * @return string|null The field name to use in queries, or null if none is available\n */\n private function getActivityFieldName(Activity $activity): ?string\n {\n if ($this->profile === null) {\n $this->logger->warning('[Salesforce] Cannot determine activity field - profile not found', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n $profileEventFields = $this->profile->getFieldsAsArray('event');\n\n if (empty($profileEventFields)) {\n $defaultActivityField = $this->getDefaultActivityField(Field::OBJECT_EVENT);\n $defaultFieldName = $defaultActivityField?->getAttribute('crm_provider_id');\n // Profile not yet synced — fall back to the default activity field.\n // There is a small chance that the profile won't have Default Activity Type field access\n // in which case the query will fail.\n // This is however an edge case and should be reviewed for profile sync issues.\n Sentry::withScope(function (\\Sentry\\State\\Scope $scope) use ($defaultFieldName): void {\n $scope->setContext('details', [\n 'profileId' => $this->profile->id,\n 'defaultField' => $defaultFieldName,\n ]);\n Sentry::captureMessage(\n '[Salesforce] Profile event fields empty, falling back to default activity field.',\n \\Sentry\\Severity::warning()\n );\n });\n\n return $defaultFieldName;\n }\n\n $playbook = $this->getPlaybook($activity->getUser());\n\n if (! is_null($playbook) && ! is_null($playbook->getActivityField())) {\n $playbookFieldName = $playbook->getActivityField()->getAttribute('crm_provider_id');\n\n if (in_array($playbookFieldName, $profileEventFields, true)) {\n return $playbookFieldName;\n }\n\n $this->logger->warning('[Salesforce] Playbook activity field not found in profile fields', [\n 'activityId' => $activity->getUuid(),\n 'playbookField' => $playbookFieldName,\n 'profileId' => $this->profile->id,\n ]);\n }\n\n return null;\n }\n\n private function buildFetchRelatedEventQuery(Activity $activity): string\n {\n $hasWho = $activity->lead_id || $activity->contact_id;\n\n $activityFieldName = $this->getActivityFieldName($activity);\n $fields = array_filter(['Id', 'Description', 'OwnerId', $activityFieldName]);\n\n $ownerCondition = '(OwnerId = :ownerId OR CreatedById = :ownerId)';\n\n $query = '\n SELECT ' . implode(',', $fields) . '\n FROM Event\n WHERE ' . $ownerCondition . '\n AND IsArchived = false\n AND IsAllDayEvent = false\n AND StartDateTime >= :from\n AND EndDateTime <= :to\n AND (';\n\n $operator = '';\n if ($activity->account_id) {\n // This covers events tied to a related contact or opportunity too.\n $query .= 'AccountId = :accountId';\n\n $operator = ' OR ';\n }\n\n if ($hasWho) {\n $query .= $operator . 'WhoId = :whoId';\n\n // If we are also going to check on a specific opportunity, set that up.\n if ($activity->opportunity_id) {\n $query .= ' OR WhatId = :whatId';\n }\n }\n\n $query .= ') ORDER BY LastModifiedDate DESC';\n\n return $query;\n }\n\n public function fetchProspect(array $task): array\n {\n $lead = $account = $opportunity = $contact = $stage = $countryCode = null;\n $externalId = $task['WhoId'] ?? null;\n\n // Lead or Contact\n if ($externalId) {\n try {\n [$lead, $account, $opportunity, $contact, $stage, $countryCode] = $this->parseRecords($externalId);\n } catch (\\InvalidArgumentException $exception) {\n // Invalid object type.\n }\n }\n\n // If we happen to know the opportunity or account from the Task, figure that out.\n if (empty($task['WhatId']) === false) {\n // WhatId could be either Account ID or Opportunity ID.\n // If WhatId is Opportunity ID, get the opportunity and stage from the CRM.\n try {\n [, $account, $opportunity, , $stage, ] = $this->parseRecords($task['WhatId']);\n } catch (\\InvalidArgumentException $exception) {\n // Invalid object type.\n }\n }\n\n return [$lead, $account, $opportunity, $contact, $stage, $countryCode];\n }\n\n /**\n * Save activity transcription summary as note\n */\n public function saveTranscriptionSummaryAsNote(\n ActivityContract $activity,\n string $title,\n string $body,\n ?string $objectId,\n ?NoteObject $noteObject = null,\n ): ?string {\n return $this->saveNote($title, $body, (string) $objectId);\n }\n\n public function getObjectByFilterConditions(string $objectType, array $fields, array $filters): ?array\n {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $query = $queryBuilder->buildObjectSearchQuery($objectType, $fields, $filters);\n\n try {\n $objects = $this->queryHandler->query($query, $filters);\n if ($objects->count() === 1) {\n return $objects->current();\n }\n } catch (\\Exception $e) {\n $this->logger->info('[Salesforce] Failed to execute query', [\n 'query' => $query,\n 'error' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n private function getCustomProfileRules(TeamRepository $teamRepository): array\n {\n $teamSettings = $teamRepository->getTeamSetting($this->team, 'custom_profile_validation');\n\n if ($teamSettings instanceof TeamSettings && $teamSettings->getValueType() === 'array') {\n $customRules = json_decode($teamSettings->getValue(), true);\n if (is_array($customRules)) {\n return $customRules;\n }\n }\n\n return [];\n }\n\n private function customProfileValidation(array $crmUser, array $customRules): bool\n {\n foreach ($customRules as $customRule) {\n if ($crmUser[$customRule['field']] !== $customRule['value']) {\n return false;\n }\n }\n\n return true;\n }\n\n /**\n * When syncing Contact / Lead / Account / Opportunity / Stage crm entities,\n * validate and restore locally trashed objects,\n * before updating them. Objects are identified by CrmProviderId\n */\n private function restoreAnyTrashedEntity(HasMany $targetEntity, string $crmProviderId): void\n {\n $recordExists = $targetEntity->withTrashed()->where(['crm_provider_id' => $crmProviderId])->first();\n if ($recordExists && $recordExists->trashed()) {\n $recordExists->restore();\n }\n }\n\n #[\\Override] public function supportsNotes(): bool\n {\n return true;\n }\n\n private function getOwnerProfile(?string $ownerId): ?Profile\n {\n if ($ownerId === null) {\n return null;\n }\n\n return $this->config->profiles()\n ->where('crm_provider_id', $ownerId)\n ->first();\n }\n}","depth":2,"bounds":{"left":0.44448137,"top":0.42697525,"width":0.28490692,"height":0.57302475},"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Salesforce;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse Illuminate\\Database\\Eloquent\\Relations\\HasMany;\nuse Illuminate\\Events\\Dispatcher;\nuse Illuminate\\Support\\Facades\\Cache;\nuse Illuminate\\Support\\Facades\\Log;\nuse Illuminate\\Support\\Str;\nuse Jiminny\\Component\\Country\\CountriesMap;\nuse Jiminny\\Contracts\\Acl\\PermissionEnum;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\FetchRelatedActivityInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\ImportsBusinessProcessesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\SalesforceBatchSyncInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\SalesforceInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteNoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SearchTaskInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SendSummaryToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SupportsObjectTypeParseInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmProfileRecordTypesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Enums\\CrmObject;\nuse Jiminny\\Events\\Activities\\Crm\\LeadConverted;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpBadRequestException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Exceptions\\NoResultsException;\nuse Jiminny\\Exceptions\\ServiceUnavailableException;\nuse Jiminny\\Jobs\\Crm\\NoteObject;\nuse Jiminny\\Jobs\\Crm\\MatchActivitiesToNewOpportunity;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Calendar\\CalendarEvent;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Contracts\\ActivityContract;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Configuration;\nuse Jiminny\\Models\\Crm\\ContactRole;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Crm\\RecordType;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\TeamSettings;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\ContactRoleRepository;\nuse Jiminny\\Repositories\\Crm\\FieldRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Repositories\\Crm\\RecordTypeFieldValuesRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\Helpers\\ArrayIterator;\nuse Jiminny\\Services\\Crm\\MatchDomainByEmailInterface;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Services\\Crm\\ResolveCompanyNameByEmailTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\Fields\\FieldHelper;\nuse Jiminny\\Services\\Crm\\Salesforce\\Fields\\FieldTypeConverter;\nuse Jiminny\\Services\\Crm\\Salesforce\\Fields\\ValueNormalizer;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\FollowupActivityTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\LogActivityTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\RecordManipulationsTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\SyncFieldsTrait;\nuse Jiminny\\Utils\\CurrencyFormatter;\nuse Jiminny\\Utils\\StringUtil;\nuse Ramsey\\Uuid\\Uuid;\nuse Sentry\\Laravel\\Facade as Sentry;\n\nclass Service extends BaseService implements\n SalesforceInterface,\n SalesforceBatchSyncInterface,\n SyncCrmEntitiesInterface,\n SyncCrmProfileRecordTypesInterface,\n ImportsBusinessProcessesInterface,\n RemoteEntityManipulationInterface,\n FetchRelatedActivityInterface,\n SendSummaryToCrmInterface,\n MatchDomainByEmailInterface,\n SearchTaskInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n SupportsObjectTypeParseInterface,\n RemoteNoteEntityManipulationInterface,\n VerifyTaskExistsInterface\n{\n use ResolveCompanyNameByEmailTrait;\n use SyncFieldsTrait;\n use DeleteObjectsTrait;\n use RecordManipulationsTrait;\n use ServiceTraits\\BatchSyncTrait;\n use FollowupActivityTrait;\n use LogActivityTrait;\n\n /**\n * Note Body Limit for the Old Note-Taking Tool\n *\n * @var int\n */\n private const int CLASSIC_NOTE_MAX_LENGTH = 32000;\n\n /**\n * Note Content Limit for the New Notes\n *\n * @var int\n */\n private const int ENHANCED_NOTE_MAX_LENGTH = 50000000;\n\n private const string INSTALLED_PACKAGE_ID = '033Tw0000007bKbIAI';\n\n private const int CACHE_TTL = 600;\n\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day - 86400\n\n /**\n * @var Client\n */\n protected $client;\n\n protected PayloadBuilder $payloadBuilder;\n protected QueryHandler $queryHandler;\n\n private OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n\n public function __construct(\n Client $client,\n PayloadBuilder $payloadBuilder,\n protected Dispatcher $eventDispatcher,\n private readonly CountriesMap $countriesMap,\n private readonly ProspectPhotoPathService $prospectPhotoPathService,\n ) {\n parent::__construct();\n\n $this->client = $client;\n $this->payloadBuilder = $payloadBuilder;\n $this->queryHandler = app(QueryHandler::class, [\n 'client' => $this->client,\n 'logger' => $this->logger,\n ]);\n $this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n }\n\n public function getDisplayName(): string\n {\n return 'Salesforce';\n }\n\n public function getJobDelay(): int\n {\n return 1;\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n return $user->getSocialAccount(SocialAccount::PROVIDER_SALESFORCE);\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($activity, $crmProviderId) {\n $playbook = $this->getPlaybookFromActivity($activity);\n\n if ($playbook === null) {\n $this->logger->warning('[Salesforce] Cannot verify task - no playbook found', [\n 'activity' => $activity->getId(),\n 'crm_provider_id' => $crmProviderId,\n ]);\n\n return false;\n }\n\n $objectType = $playbook->getActivityType() === Playbook::ACTIVITY_TYPE_EVENT ? 'Event' : 'Task';\n\n try {\n $record = $this->getRecord($objectType, $crmProviderId, ['Id', 'IsDeleted']);\n\n return ! empty($record) && ($record['IsDeleted'] ?? false) === false;\n } catch (HttpNotFoundException|HttpBadRequestException) {\n $this->logger->info('[Salesforce] Activity record not found during verification', [\n 'activity' => $activity->getId(),\n 'object_type' => $objectType,\n 'crm_provider_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n });\n }\n\n public function query(string $queryToRun, array $parameters = []): QueryIterator\n {\n // Due to poorly designed external calls, this method cannot be entirely removed\n return $this->queryHandler->query($queryToRun, $parameters);\n }\n\n /*=========== Organization Information ===============*/\n\n /**\n * Get a list of all the API Versions for the instance.\n *\n * @throws CrmException\n *\n * @return mixed\n *\n */\n public function getApiVersions()\n {\n $url = $this->config->crm_base_url . '/services/data';\n\n $response = $this->client->get($url);\n\n return json_decode($response->getBody(), true);\n }\n\n /**\n * Gets the valid recordTypes for a given Salesforce Object via the describe API.\n */\n private function getRecordTypes(string $crmObject): array\n {\n $url = $this->client->getObjectsUrl() . $crmObject . '/describe';\n\n $response = $this->client->get($url);\n $jsonResponse = json_decode($response->getBody(), true);\n\n $fields = [];\n foreach ($jsonResponse['recordTypeInfos'] as $row) {\n $fields[] = ['recordTypeId' => $row['recordTypeId'], 'default' => $row['defaultRecordTypeMapping']];\n }\n\n return $fields;\n }\n\n /**\n * Convert raw field data into a format compatible with CRM APIs.\n */\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return ValueNormalizer::normalize($fieldType, $fieldValue, $internal);\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n $defaultFields = ($activityType === Playbook::ACTIVITY_TYPE_TASK)\n ? FieldDefinitions::defaultTaskFields()\n : FieldDefinitions::defaultEventFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n // Setup the activity field as the default Type.\n /** @var Field $activityField */\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'Type',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK, Playbook::ACTIVITY_TYPE_EVENT];\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n\n $fieldFilter = ($activityType === Playbook::ACTIVITY_TYPE_TASK)\n ? FieldDefinitions::taskFollowupFieldsFilter()\n : FieldDefinitions::eventFollowupFieldsFilter();\n\n foreach ($fieldFilter as $eachFilter) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $eachFilter);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n /**\n * This one is now called only when ImportActivityTypes is triggered or SyncFieldMetadata executed manually\n * Regular sync now uses SharedSyncFieldsTrait -> syncSingleObjectType\n * Needs to be replaced later on\n */\n public function syncField(Field $field): void\n {\n try {\n if (FieldHelper::isCustomField($field)) {\n $query = '\n SELECT\n Id, Metadata, TableEnumOrId\n FROM\n CustomField\n WHERE\n DeveloperName = :fieldName\n AND\n TableEnumOrId = :fieldType\n AND\n NamespacePrefix = :namespacePrefix';\n\n // We need to constrain the field lookup to the object, in case it's used in multiple places.\n $objectType = \\in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true)\n ? 'activity'\n : $field->object_type;\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'fieldName' => substr($field->crm_provider_id, 0, -\\strlen('__c')),\n 'fieldType' => ucfirst($objectType),\n\n // This is used to ensure we only consider the field within the org, not installed packages.\n 'namespacePrefix' => 'null',\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n // Sync field metadata.\n $metadata = $sfField['Metadata'];\n\n $field->description = mb_strimwidth($metadata['description'] ?? '', 0, 191);\n $field->label = mb_strimwidth($metadata['label'] ?? '', 0, Field::LABEL_MAX_LENGTH);\n $field->type = $this->convertFieldType($metadata['type'], $field->getEntityName());\n $field->is_mandatory = ($metadata['required'] === true);\n $field->length = $metadata['length'];\n $field->default_value = mb_strimwidth(trim($metadata['defaultValue'] ?? '', '\"'), 0, 191);\n $field->save();\n } else {\n $query = '\n SELECT\n Id, DataType, DeveloperName, Label, Length, Description\n FROM\n FieldDefinition\n WHERE\n DurableId = :entityName';\n\n $entityName = $field->getEntityName();\n $sfFields = $this->queryHandler->metadata($query, [\n 'entityName' => $entityName,\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n $convertedType = $this->convertFieldType($sfField['DataType'], $entityName);\n $label = mb_strimwidth($sfField['Label'], 0, Field::LABEL_MAX_LENGTH);\n\n if ($field->isBusinessType()) {\n $label = 'Opportunity Type';\n }\n\n $field->description = mb_strimwidth($sfField['Description'], 0, Field::DESCRIPTION_MAX_LENGTH);\n $field->label = $label;\n $field->type = $convertedType;\n $field->length = $sfField['Length'];\n $field->save();\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n private function convertFieldType(string $from, ?string $entityName = null): string\n {\n $converter = new FieldTypeConverter();\n\n return $converter->convert($from, $entityName);\n }\n\n /**\n * @inheritdoc\n */\n public function importPicklistValues(Field $field): array\n {\n $values = [];\n $fieldValues = [];\n\n try {\n if (FieldHelper::isCustomField($field)) {\n $query = '\n SELECT\n Id, Metadata, TableEnumOrId\n FROM\n CustomField\n WHERE\n DeveloperName = :fieldName\n AND\n TableEnumOrId = :fieldType\n AND\n NamespacePrefix = :namespacePrefix';\n\n // We need to constrain the field lookup to the object, in case it's used in multiple places.\n $objectType = \\in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true) ?\n 'activity' : $field->object_type;\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'fieldName' => substr($field->crm_provider_id, 0, -\\strlen('__c')),\n 'fieldType' => ucfirst($objectType),\n // This is used to ensure we only consider the field within the org, not installed packages.\n 'namespacePrefix' => 'null',\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n $valueSet = $sfField['Metadata']['valueSet'];\n\n if ($valueSet['valueSetName'] === null) {\n // Local picklist values can be obtained easily.\n $picklistValues = $valueSet['valueSetDefinition']['value'];\n } else {\n // But for some fields, we just get the Global Value Picklist pointer so need to do more work.\n $picklistValues = $this->importGlobalValuePicklistValues($valueSet['valueSetName']);\n }\n\n // Import all active values.\n foreach ($picklistValues as $i => $sfFieldValue) {\n // Setup default value.\n if ($sfFieldValue['default']) {\n $field->update(['default_value' => $sfFieldValue['valueName']]);\n }\n\n // This comes through as null if active (lol).\n if ($sfFieldValue['isActive'] !== false) {\n $values[] = [\n 'value' => $sfFieldValue['valueName'],\n 'label' => $sfFieldValue['valueName'],\n 'sequence' => $i,\n 'is_default' => $sfFieldValue['default'],\n ];\n }\n }\n } else {\n $objectFields = $this->getObjectFields($field->object_type);\n $fieldId = $field->crm_provider_id;\n\n // Only work with our field of interest.\n $objectField = array_filter($objectFields, function ($item) use ($fieldId) {\n return $item['name'] === $fieldId;\n });\n\n $objectField = array_shift($objectField);\n if (empty($objectField['picklistValues']) === false) {\n foreach ($objectField['picklistValues'] as $i => $sfFieldValue) {\n // Skip inactive values.\n if ($sfFieldValue['active'] === false) {\n continue;\n }\n\n // Setup default value.\n if ($sfFieldValue['defaultValue']) {\n $field->update(['default_value' => $sfFieldValue['value']]);\n }\n\n $values[] = [\n 'value' => $sfFieldValue['value'],\n 'label' => $sfFieldValue['label'],\n 'sequence' => $i,\n 'is_default' => $sfFieldValue['defaultValue'],\n ];\n }\n }\n }\n\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n $value['value'] = substr($value['value'] ?? '', 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n if (($key = array_search($value['value'], $fieldsToPurge, true)) !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n // Get IDs of the values to be deleted\n $valuesToDelete = $field->values()->whereIn('value', $fieldsToPurge);\n $valuesToDeleteIds = $valuesToDelete->pluck('id');\n if (! $valuesToDeleteIds->isEmpty()) {\n $recordTypeFieldValuesRepository = app(RecordTypeFieldValuesRepository::class);\n $recordTypeFieldValuesRepository->deleteForCrmFieldValueIds($valuesToDeleteIds->toArray());\n\n // Now safely delete from crm_field_values\n $valuesToDelete->delete();\n }\n\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n return $fieldValues;\n }\n\n /**\n * Gets values from Global Value Picklists.\n */\n private function importGlobalValuePicklistValues(string $picklistName): array\n {\n $query = '\n SELECT\n Metadata\n FROM\n GlobalValueSet\n WHERE\n DeveloperName = :picklistName\n LIMIT 1';\n\n try {\n $sfValues = $this->queryHandler->metadata($query, [\n 'picklistName' => $picklistName,\n ]);\n\n // There is always 1 result at this point.\n $sfValue = $sfValues->current();\n\n return $sfValue['Metadata']['customValue'];\n } catch (NoResultsException $noResultsException) {\n // Nothing returned.\n\n return [];\n }\n }\n\n /**\n * @inheritdoc\n */\n public function syncProfileRecordTypes(): void\n {\n $objectTypes = [\n 'lead',\n 'account',\n 'contact',\n 'opportunity',\n 'task',\n 'event',\n ];\n\n foreach ($objectTypes as $objectType) {\n try {\n $crmRecordTypes = $this->getRecordTypes(ucfirst($objectType));\n\n foreach ($crmRecordTypes as $crmRecordType) {\n // If the record type is default and not the Master type, set this.\n if ($crmRecordType['default'] && $crmRecordType['recordTypeId'] !== '012000000000000AAA') {\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $crmRecordType['recordTypeId'])\n ->first();\n\n if ($recordType) {\n $this->profile->{$objectType . '_record_type_id'} = $recordType->id;\n }\n }\n }\n } catch (HttpNotFoundException $exception) {\n Log::error('No access to ' . $objectType . ' object, skipping...');\n\n // XXX: should we log this fact somewhere?\n continue;\n }\n }\n\n if ($this->profile->isDirty()) {\n $this->profile->save();\n }\n }\n\n /**\n * Gets business processes.\n */\n public function importBusinessProcesses(): void\n {\n $query = '\n SELECT\n Id, IsActive, Name, TableEnumOrId\n FROM\n BusinessProcess\n WHERE\n TableEnumOrId IN (\\'Lead\\',\\'Opportunity\\')';\n\n try {\n $sfProcesses = $this->queryHandler->query($query);\n\n // Upsert all processes for the team.\n foreach ($sfProcesses as $sfProcess) {\n /** @var BusinessProcess $businessProcess */\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $sfProcess['Id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => $sfProcess['Name'],\n 'type' => $sfProcess['TableEnumOrId'] === 'Lead' ? 'lead' : 'opportunity',\n 'is_selectable' => $sfProcess['IsActive'],\n ]);\n\n $this->importBusinessProcessStages($businessProcess);\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n /**\n * Gets business process stages.\n */\n private function importBusinessProcessStages(BusinessProcess $businessProcess): void\n {\n $query = '\n SELECT\n Metadata\n FROM\n BusinessProcess\n WHERE\n Id = :processId';\n\n try {\n $stages = [];\n $sfProcessStages = $this->queryHandler->metadata($query, [\n 'processId' => $businessProcess->crm_provider_id,\n ]);\n\n // There is always 1 result at this point.\n $sfProcessStage = $sfProcessStages->current();\n\n // Upsert all processes for the team.\n foreach ($sfProcessStage['Metadata']['values'] as $sfProcessStage) {\n $sanitizedName = urldecode($sfProcessStage['valueName']); // Must decode: \"%2C\" becomes \",\" etc.\n\n $stage = $businessProcess->crm->stages()\n // This MUST match on label because this API doesn't use API Name.\n ->where('label', $sanitizedName)\n ->where('type', $businessProcess->type)\n ->where('is_selectable', 1)\n ->first();\n\n if ($stage) {\n $stages[] = $stage->id;\n }\n }\n\n $businessProcess->stages()->sync($stages);\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n /**\n * Gets record types.\n */\n public function importRecordTypes(): void\n {\n $query = '\n SELECT\n Id, IsActive, Name, BusinessProcessId, SobjectType\n FROM\n RecordType';\n\n try {\n $sfRecordTypes = $this->queryHandler->query($query);\n\n // Upsert all record types for the process.\n foreach ($sfRecordTypes as $sfRecordType) {\n $businessProcess = null;\n if ($sfRecordType['BusinessProcessId']) {\n $businessProcess = $this->config->businessProcesses()\n ->where('crm_provider_id', $sfRecordType['BusinessProcessId'])\n ->first();\n }\n\n /** @var RecordType $recordType */\n $recordType = $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $sfRecordType['Id'],\n ], [\n 'team_id' => $this->team->id,\n 'type' => mb_strtolower($sfRecordType['SobjectType']),\n 'name' => $sfRecordType['Name'],\n 'is_selectable' => $sfRecordType['IsActive'],\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n $this->importRecordTypeFieldValues($recordType);\n }\n } catch (NoResultsException $noResultsException) {\n // Do nothing.\n }\n }\n\n /**\n * Import record type - field value mappings. This only works for standard fields.\n */\n private function importRecordTypeFieldValues(RecordType $recordType): void\n {\n try {\n $query = '\n SELECT\n Metadata\n FROM\n RecordType\n WHERE\n Id = :recordTypeId';\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'recordTypeId' => $recordType->crm_provider_id,\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n // Sync field metadata.\n $picklists = $sfField['Metadata']['picklistValues'];\n\n foreach ($picklists as $picklist) {\n $field = $this->config->fields()->where([\n 'type' => Field::TYPE_PICKLIST,\n 'object_type' => $recordType->type,\n 'crm_provider_id' => $picklist['picklist'],\n ])->first();\n\n if ($field) {\n $fieldValues = [];\n\n foreach ($picklist['values'] as $value) {\n // Must decode: \"%2C\" becomes \",\" etc.\n $fieldValue = $field->values()\n ->where('value', urldecode($value['valueName']))\n ->first();\n\n if ($fieldValue) {\n $fieldValues[] = $fieldValue->id;\n }\n }\n\n $recordType->fieldValues()->sync($fieldValues);\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n /**\n * @inheritdoc\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $params = [];\n $missingStage = null;\n if ($types === null) {\n $types = [Stage::TYPE_LEAD, Stage::TYPE_OPPORTUNITY];\n }\n\n foreach ($types as $type) {\n if ($type === Stage::TYPE_LEAD) {\n $query = '\n SELECT\n Id, ApiName, MasterLabel, SortOrder\n FROM\n LeadStatus';\n } else {\n $query = '\n SELECT\n Id, ApiName, MasterLabel, IsActive, SortOrder, DefaultProbability\n FROM\n OpportunityStage';\n }\n\n if ($missingStageName) {\n $escapedStageName = ValueNormalizer::replaceQueryWithStringLiterals($missingStageName);\n\n $query .= ' WHERE ApiName = :stageName';\n\n $params = [\n 'stageName' => $escapedStageName,\n ];\n }\n\n try {\n $sfStages = $this->queryHandler->query($query, $params);\n } catch (NoResultsException $exception) {\n $sfStages = [];\n }\n\n $missingStage = null;\n\n // Upsert all stages for the team.\n foreach ($sfStages as $sfStage) {\n $selectable = true;\n if (array_key_exists('IsActive', $sfStage)) {\n $selectable = $sfStage['IsActive'];\n }\n\n $this->restoreAnyTrashedEntity($this->config->stages(), $sfStage['Id']);\n\n $stage = $this->config->stages()->updateOrCreate([\n 'crm_provider_id' => $sfStage['Id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($sfStage['ApiName'], 0, 50),\n 'label' => mb_strimwidth($sfStage['MasterLabel'], 0, 191),\n 'type' => $type,\n 'sequence' => $sfStage['SortOrder'] ?? 0,\n 'is_selectable' => $selectable,\n 'probability' => $sfStage['DefaultProbability'] ?? null,\n ]);\n\n if ($missingStageName && $missingStageName === $sfStage['ApiName']) {\n $missingStage = $stage;\n }\n }\n\n if ($missingStageName && $missingStage === null) {\n // If they requested a stage that still doesn't exist, it must be inactive so lazy create it.\n $missingStage = $this->config->stages()->create([\n 'crm_provider_id' => Uuid::uuid4(),\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($missingStageName, 0, 50),\n 'label' => mb_strimwidth($missingStageName, 0, 191),\n 'type' => $type,\n 'sequence' => 0,\n 'is_selectable' => 0,\n ]);\n }\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int\n {\n $syncCount = 0;\n $fields = $this->getAllFieldsAsArray('lead');\n if (\\in_array('Id', $fields, true) === false) {\n return $syncCount;\n }\n\n $query = '\n SELECT ' . rtrim(implode(',', $fields), ',') . '\n FROM Lead\n WHERE LastModifiedDate > :since\n ORDER BY LastModifiedDate ASC';\n\n try {\n $sfLeads = $this->queryHandler->query($query, [\n 'since' => $since->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($sfLeads as $sfLead) {\n // Only sync if previously imported.\n if ($this->hasLead($sfLead['Id'])) {\n $this->importLead($sfLead);\n $syncCount++;\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::LEAD);\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLead(string $crmId): ?Lead\n {\n $fields = $this->getAllFieldsAsArray('lead');\n\n $sfLead = $this->getRecord('Lead', $crmId, $fields);\n\n return $this->importLead($sfLead);\n }\n\n private function importLead($crmData): ?Lead\n {\n /** @var ?Stage $stage */\n $stage = null;\n if (isset($crmData['Status'])) {\n // Get the current stage.\n $stage = $this->config\n ->stages()\n ->where('name', $crmData['Status'])\n ->where('type', Stage::TYPE_LEAD)\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_LEAD], $crmData['Status']);\n }\n }\n\n // If we have no way of importing this, just return null :(\n if ($stage === null) {\n return null;\n }\n\n $countryCode = $crmData['CountryCode'] ?? null;\n\n // Salesforce allows custom \"countries\" to be created. Disregard these.\n if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {\n $countryCode = null;\n }\n\n // If we have no country code, try to parse it from the country name.\n if ($countryCode === null && empty($crmData['Country']) !== false) {\n $countryCode = $this->convertCountryNameToCode($crmData['Country']);\n }\n\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($crmData['Phone'] ?? '', 0, 25);\n $parsedNumber = parsePhoneNumber($countryCode, $number);\n\n $mobilePhone = null;\n if (empty($crmData['MobilePhone']) === false) {\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($crmData['MobilePhone'], 0, 25);\n $mobilePhone = phone_e164($countryCode, $number);\n }\n\n $convertedDate = null;\n $convertedAccount = null;\n $convertedOpportunity = null;\n $convertedContact = null;\n\n if ($crmData['IsConverted'] == 'true') {\n $convertedDate = $crmData['ConvertedDate'];\n\n if (empty($crmData['ConvertedAccountId']) === false) {\n $convertedAccount = $this->config\n ->accounts()\n ->where('crm_provider_id', $crmData['ConvertedAccountId'])\n ->first();\n\n if ($convertedAccount === null) {\n try {\n $convertedAccount = $this->syncAccount($crmData['ConvertedAccountId']);\n } catch (HttpNotFoundException $exception) {\n // Probably the user has no permissions to access the converted data.\n }\n }\n }\n\n if (empty($crmData['ConvertedOpportunityId']) === false) {\n $convertedOpportunity = $this->config\n ->opportunities()\n ->where('crm_provider_id', $crmData['ConvertedOpportunityId'])\n ->first();\n\n if ($convertedOpportunity === null) {\n try {\n $convertedOpportunity = $this->syncOpportunity($crmData['ConvertedOpportunityId']);\n } catch (HttpNotFoundException $exception) {\n // Probably the user has no permissions to access the converted data.\n }\n }\n }\n\n if (empty($crmData['ConvertedContactId']) === false) {\n $convertedContact = $this->team\n ->crm\n ->contacts()\n ->where('crm_provider_id', $crmData['ConvertedContactId'])\n ->first();\n\n if ($convertedContact === null) {\n try {\n $convertedContact = $this->syncContact($crmData['ConvertedContactId']);\n } catch (HttpNotFoundException $exception) {\n // Probably the user has no permissions to access the converted data.\n }\n }\n }\n }\n\n if (empty($crmData['Company'])) {\n $company = 'Unknown';\n } else {\n $company = mb_strimwidth($crmData['Company'], 0, 191);\n }\n\n $domain = null;\n if (empty($crmData['Website']) === false) {\n $domain = mb_strimwidth($crmData['Website'], 0, 191);\n $domain = StringUtil::resolveDomain($domain);\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $data = [\n 'team_id' => $this->team->id,\n 'user_id' => $profile?->user_id,\n 'owner_id' => $crmData['OwnerId'] ?? '',\n 'company' => $company,\n 'domain' => $domain,\n 'name' => $crmData['Name'] ? mb_strimwidth($crmData['Name'], 0, 191) : '',\n 'title' => $crmData['Title'] ? mb_strimwidth($crmData['Title'], 0, 128) : null,\n 'email' => $crmData['Email'] ? mb_strimwidth($crmData['Email'], 0, 80) : null,\n 'phone' => $parsedNumber['phone'],\n 'ext' => $parsedNumber['ext'] ?? null,\n 'mobile_phone' => $mobilePhone,\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['Id'],\n modelType: Lead::class,\n fileName: $crmData['Id'],\n avatarText: $crmData['Name']\n ),\n 'stage_id' => $stage->id,\n 'record_type_id' => null,\n 'converted_at' => $convertedDate,\n 'converted_account_id' => $convertedAccount->id ?? null,\n 'converted_opportunity_id' => $convertedOpportunity->id ?? null,\n 'converted_contact_id' => $convertedContact->id ?? null,\n 'country_code' => $countryCode,\n 'remotely_created_at' => $createdDate,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->leads(), $crmData['Id']);\n\n /** @var Lead $lead */\n $lead = $this->config->leads()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n if ($lead->wasChanged('converted_at') && $lead->getConvertedAt() !== null) {\n $this->eventDispatcher->dispatch(new LeadConverted($lead));\n }\n\n $this->handleObjectDeletion($lead, $crmData);\n\n if ($lead->trashed()) {\n return null;\n }\n\n return $lead;\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccounts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n $fields = $this->getAllFieldsAsArray('account');\n\n if (\\in_array('Id', $fields, true) === false) {\n return $syncCount;\n }\n\n $query = '\n SELECT ' . rtrim(implode(',', $fields), ',') . '\n FROM Account\n WHERE LastModifiedDate > :since\n ORDER BY LastModifiedDate ASC';\n\n try {\n $sfAccounts = $this->queryHandler->query($query, [\n 'since' => $since->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($sfAccounts as $sfAccount) {\n // Only sync if previously imported.\n if ($this->hasAccount($sfAccount['Id'])) {\n $this->importAccount($sfAccount);\n $syncCount++;\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::ACCOUNT);\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccount(string $crmId): ?Account\n {\n $fields = $this->getAllFieldsAsArray('account');\n if (! in_array('Id', $fields, true)) {\n $this->logger->info('[Salesforce] Sync account cancelled. Fields are not available.', [\n 'crmId' => $crmId,\n 'userId' => $this->profile->getUserId(),\n ]);\n\n return null;\n }\n\n $sfAccount = $this->getRecord('Account', $crmId, $fields);\n\n return $this->importAccount($sfAccount);\n }\n\n private function importAccount($crmData): Account\n {\n $countryCode = $crmData['BillingCountryCode'] ?? $crmData['ShippingCountryCode'] ?? null;\n\n // Salesforce allows custom \"countries\" to be created. Disregard these.\n if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {\n $countryCode = null;\n }\n\n // If we have no country code, try to parse it from the country names.\n if ($countryCode === null && empty($crmData['BillingCountry']) === false) {\n $countryCode = $this->convertCountryNameToCode($crmData['BillingCountry']);\n }\n\n if ($countryCode === null && empty($crmData['ShippingCountry']) === false) {\n $countryCode = $this->convertCountryNameToCode($crmData['ShippingCountry']);\n }\n\n if (empty($crmData['Phone']) === false) {\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($crmData['Phone'], 0, 25);\n $parsedNumber = parsePhoneNumber($countryCode, $number);\n } else {\n $parsedNumber = [];\n }\n\n $industry = null;\n if (empty($crmData['Industry']) === false) {\n $industry = mb_strimwidth($crmData['Industry'], 0, 40);\n }\n\n $domain = null;\n if (empty($crmData['Website']) === false) {\n $domain = mb_strimwidth($crmData['Website'], 0, 191);\n $domain = StringUtil::resolveDomain($domain);\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $data = [\n 'team_id' => $this->team->id,\n 'user_id' => $profile?->user_id,\n 'owner_id' => $crmData['OwnerId'],\n 'name' => mb_strimwidth($crmData['Name'], 0, 191),\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['Id'],\n modelType: Account::class,\n fileName: $crmData['Id'],\n avatarText: $crmData['Name']\n ),\n 'industry' => $industry,\n 'domain' => $domain,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'ext' => $parsedNumber['ext'] ?? null,\n 'country_code' => $countryCode,\n 'remotely_created_at' => $createdDate,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->accounts(), $crmData['Id']);\n\n /** @var Account $account */\n $account = $this->config->accounts()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n $this->handleObjectDeletion($account, $crmData);\n\n return $account;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOpportunities(array $parameters, ?string $strategy = null): int\n {\n $strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);\n\n $syncCount = 0;\n $logParams = $parameters;\n $parameters['profile'] = $this->profile;\n $logParams['user'] = $this->profile->getUserId();\n\n if (count($strategies) > 1) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Multiple sync strategies used', [\n 'teamId' => $this->team->getUuid(),\n 'params' => $logParams,\n 'strategies_count' => count($strategies),\n ]);\n }\n\n foreach ($strategies as $syncStrategy) {\n $name = $syncStrategy->getStrategyName();\n\n try {\n $sfOpportunities = $syncStrategy->fetchOpportunities($parameters);\n $totalRecords = $sfOpportunities->count();\n\n foreach ($sfOpportunities as $sfOpportunity) {\n $this->importOpportunity($sfOpportunity);\n $syncCount++;\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n $this->logger->warning('[' . $this->getDisplayName() . '] No opportunities found', [\n 'teamId' => $this->team->getUuid(),\n 'name' => $name,\n 'params' => $logParams,\n 'reason' => $noResultsException->getMessage(),\n ]);\n } catch (CrmException $crmException) {\n // Nothing to sync.\n $this->logger->warning('[' . $this->getDisplayName() . '] Opportunity sync failed', [\n 'teamId' => $this->team->getUuid(),\n 'name' => $name,\n 'params' => $logParams,\n 'reason' => $crmException->getMessage(),\n ]);\n }\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::OPPORTUNITY, ['params' => $logParams]);\n\n // debug to see how if count of opportunities reaches 1000\n if ($syncCount >= 1000) {\n $this->logger->info(\n '[' . $this->getDisplayName() . '] Sync Opportunities - count warning',\n [\n 'team_id' => $this->team->getId(),\n 'params' => $logParams,\n 'count' => $syncCount,\n 'strategies_count' => count($strategies),\n 'total_records' => $totalRecords ?? null,\n ]\n );\n }\n\n return $syncCount;\n }\n\n\n /**\n * @inheritdoc\n */\n public function syncOpportunity(string $crmId): ?Opportunity\n {\n $strategy = $this->opportunitySyncStrategyResolver->resolve(\n $this->config,\n OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY\n );\n\n $parameters = [\n 'profile' => $this->profile,\n 'crm_id' => $crmId,\n ];\n\n try {\n $sfOpportunity = $strategy->fetchOpportunities($parameters);\n } catch (HttpNotFoundException $e) {\n $this->logger->info('[' . $this->getDisplayName() . '] Opportunity not found', [\n 'teamId' => $this->team->id_string,\n 'crmId' => $crmId,\n ]);\n\n return null;\n } catch (CrmException $crmException) {\n $this->logger->info('[' . $this->getDisplayName() . '] Opportunity sync failed', [\n 'teamId' => $this->team->id_string,\n 'crmId' => $crmId,\n 'exception' => $crmException->getMessage(),\n ]);\n\n return null;\n }\n\n if ($sfOpportunity instanceof ArrayIterator) {\n return $this->importOpportunity($sfOpportunity->getItems());\n }\n\n return $this->importOpportunity($sfOpportunity);\n }\n\n /**\n * @throws HttpNotFoundException\n */\n private function importOpportunity($crmData): ?Opportunity\n {\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $account = null;\n if (empty($crmData['AccountId']) === false) {\n /** @var ?Account $account */\n $account = $this->config->accounts()\n ->where('crm_provider_id', (string) $crmData['AccountId'])\n ->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmData['AccountId']);\n }\n }\n\n $userId = $profile?->getUserId() ?? $account?->getUserId();\n if ($userId === null) {\n $this->logger->error('[Salesforce] | Skip import, no user_id found', [\n 'id' => $crmData['Id'],\n ]);\n\n return null;\n }\n\n /** @var ?Stage $stage */\n $stage = null;\n if (isset($crmData['StageName'])) {\n $stage = $this->config\n ->stages()\n ->where('name', $crmData['StageName'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->orderBy('is_selectable', 'DESC')\n ->orderBy('id')\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $crmData['StageName']);\n }\n }\n\n $recordType = null;\n if (empty($crmData['RecordTypeId']) === false) {\n /** @var ?RecordType $recordType */\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $crmData['RecordTypeId'])\n ->first();\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $closeDate = null;\n if (empty($crmData['CloseDate']) === false) {\n $closeDate = Carbon::parse($crmData['CloseDate'])->format('Y-m-d');\n }\n\n $valueFieldName = 'Amount';\n if ($this->config->opportunity_value_field_id) {\n $valueFieldName = $this->config->opportunityValueField->crm_provider_id;\n }\n\n $data = [\n 'team_id' => $this->team->id,\n 'account_id' => $account->id ?? null,\n 'user_id' => $userId,\n 'owner_id' => $crmData['OwnerId'] ?? null,\n 'name' => mb_strimwidth($crmData['Name'] ?? '', 0, 128),\n 'value' => $crmData[$valueFieldName],\n 'currency_code' => CurrencyFormatter::formatCode($crmData['CurrencyIsoCode'] ?? null),\n 'close_date' => $closeDate,\n 'is_closed' => $crmData['IsClosed'],\n 'is_won' => $crmData['IsWon'],\n 'stage_id' => $stage?->id ?? null,\n 'record_type_id' => $recordType->id ?? null,\n 'remotely_created_at' => $createdDate,\n 'probability' => $crmData['Probability'] ?? null,\n 'forecast_category' => $crmData['ForecastCategoryName'] ?? null,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->opportunities(), $crmData['Id']);\n\n // Do not allow locked DB tables & other errors\n // to interrupt the process of reverting the trashed opportunities\n try {\n /** @var Opportunity $opportunity */\n $opportunity = $this->config->opportunities()\n ->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n // import external fields into crm_field_data if present\n $crmFields = $this->getOpportunitySyncableFields();\n\n $this->importOpportunityCrmFieldData($crmData, $crmFields, $opportunity->id);\n\n $this->handleObjectDeletion($opportunity, $crmData);\n\n if ($opportunity->trashed()) {\n return null;\n }\n\n if ($opportunity->wasRecentlyCreated) {\n MatchActivitiesToNewOpportunity::dispatch($opportunity->getId());\n }\n\n return $opportunity;\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n $this->logger->error('[Salesforce] importOpportunity failure.', [\n 'crm_provider_id' => $crmData['Id'],\n 'team_id' => $this->team->id,\n 'exception' => $exception->getMessage(),\n ]);\n\n $this->handleEntityDeletionByProviderId($this->config->opportunities(), $crmData);\n }\n\n return null;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContacts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n $fields = $this->getAllFieldsAsArray('contact');\n if (\\in_array('Id', $fields, true) === false) {\n return $syncCount;\n }\n\n $query = '\n SELECT ' . rtrim(implode(',', $fields), ',') . '\n FROM Contact\n WHERE LastModifiedDate > :since\n ORDER BY LastModifiedDate ASC';\n\n try {\n $sfContacts = $this->queryHandler->query($query, [\n 'since' => $since->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($sfContacts as $sfContact) {\n // Only sync if previously imported.\n if ($this->hasContact($sfContact['Id'])) {\n $this->importContact($sfContact);\n $syncCount++;\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::CONTACT);\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContact(string $crmId): ?Contact\n {\n $fields = $this->getAllFieldsAsArray('contact');\n if (! in_array('Id', $fields, true)) {\n $this->logger->info('[Salesforce] Sync contact cancelled. Fields are not available.', [\n 'crmId' => $crmId,\n 'userId' => $this->profile->getUserId(),\n ]);\n\n return null;\n }\n\n $sfContact = $this->getRecord('Contact', $crmId, $fields);\n\n return $this->importContact($sfContact);\n }\n\n private function importContact($crmData): ?Contact\n {\n $account = null;\n // Contacts may not have accounts...\n if (isset($crmData['AccountId'])) {\n $account = $this->config->accounts()\n ->where('crm_provider_id', (string) $crmData['AccountId'])\n ->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmData['AccountId']);\n }\n }\n\n $countryCode = $crmData['MailingCountryCode'] ?? null;\n\n // Salesforce allows custom \"countries\" to be created. Disregard these.\n if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {\n $countryCode = null;\n }\n\n // If we have no country code, try to parse it from the country name.\n if ($countryCode === null && empty($crmData['MailingCountry']) === false) {\n $countryCode = $this->convertCountryNameToCode($crmData['MailingCountry']);\n\n if ($countryCode === null && $account) {\n $countryCode = $account->country_code;\n }\n }\n\n $ext = null;\n $parsedNumber = [];\n if (empty($crmData['Phone']) === false) {\n $number = Str::limit($crmData['Phone'], 25, '');\n $parsedNumber = parsePhoneNumber($countryCode, $number);\n\n if (empty($parsedNumber['ext']) === false) {\n $ext = Str::limit($parsedNumber['ext'], 10, '');\n }\n }\n\n $mobileNumber = null;\n if (empty($crmData['MobilePhone']) === false) {\n $mobileNumber = Str::limit(phone_e164($countryCode, $crmData['MobilePhone']), 25, '');\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $data = [\n 'team_id' => $this->team->id,\n 'account_id' => $account->id ?? null,\n 'user_id' => $profile?->user_id,\n 'owner_id' => $crmData['OwnerId'] ?? null,\n 'name' => ($crmData['Name'] ?? null) !== null ? mb_strimwidth($crmData['Name'], 0, 100) : '',\n 'title' => ($crmData['Title'] ?? null) !== null ? mb_strimwidth($crmData['Title'], 0, 128) : null,\n 'email' => ($crmData['Email'] ?? null) !== null ? mb_strimwidth($crmData['Email'], 0, 191) : null,\n 'country_code' => $countryCode,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'ext' => $ext,\n 'mobile_phone' => $mobileNumber,\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['Id'],\n modelType: Contact::class,\n fileName: $crmData['Id'],\n avatarText: $crmData['Name']\n ),\n 'remotely_created_at' => $createdDate,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->contacts(), $crmData['Id']);\n\n /** @var Contact $contact */\n $contact = $this->config->contacts()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n $this->handleObjectDeletion($contact, $crmData);\n\n if ($contact->trashed()) {\n return null;\n }\n\n return $contact;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n $fields = [\n 'InstanceName',\n 'OrganizationType',\n 'IsSandbox',\n ];\n\n $orgValues = $this->getRecord('Organization', $this->config->crm_provider_id, $fields);\n\n $edition = null;\n switch ($orgValues['OrganizationType']) {\n case 'Developer Edition':\n $edition = Configuration::EDITION_DEVELOPER;\n\n break;\n\n case 'Professional Edition':\n $edition = Configuration::EDITION_PROFESSIONAL;\n\n break;\n\n case 'Enterprise Edition':\n $edition = Configuration::EDITION_ENTERPRISE;\n\n break;\n }\n\n $this->config->edition = $edition;\n $this->config->instance = $orgValues['InstanceName'];\n\n // XXX: How can this state be possible?\n if ($this->config->version === null) {\n $this->config->version = Client::MIN_API_VERSION;\n }\n\n $installedVersion = $this->getInstalledAppVersion();\n if ($installedVersion !== null) {\n $installedVersion = (string) $this->getInstalledAppVersion();\n }\n\n $this->config->installed_app_version = $installedVersion;\n\n $this->config->save();\n }\n\n public function getInstalledAppVersion(): ?string\n {\n try {\n $query = '\n SELECT\n SubscriberPackageVersion.MajorVersion,\n SubscriberPackageVersion.MinorVersion,\n SubscriberPackageVersion.PatchVersion,\n SubscriberPackageVersion.BuildNumber\n FROM\n InstalledSubscriberPackage\n WHERE\n SubscriberPackageId = :packageId\n ';\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'packageId' => self::INSTALLED_PACKAGE_ID,\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n // Grab version number.\n $version = $sfField['SubscriberPackageVersion']['MajorVersion'] .\n $sfField['SubscriberPackageVersion']['MinorVersion'] .\n $sfField['SubscriberPackageVersion']['PatchVersion'] .\n $sfField['SubscriberPackageVersion']['BuildNumber'];\n } catch (\\Exception) {\n $version = null;\n }\n\n return $version;\n }\n\n /**\n * Store transcripts as note.\n *\n * @throws \\Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // For SF we also check if Log Notes is enabled.\n if ($this->profile->log_notes === Profile::LOG_NOTE_NONE) {\n return;\n }\n\n if ($activity->opportunity_id && $activity->prospect === null) {\n return;\n }\n\n try {\n $transcriptionData = $this->generateTranscription($activity);\n\n $noteMaxLength = $this->profile->log_notes === Profile::LOG_NOTE_ENHANCED\n ? self::ENHANCED_NOTE_MAX_LENGTH\n : self::CLASSIC_NOTE_MAX_LENGTH;\n\n $title = 'Transcript for ';\n $title .= $activity->title ?? $activity->activity_title;\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $body = mb_strimwidth($transcriptionData, 0, $noteMaxLength);\n\n if ($activity->opportunity_id) {\n $objectId = $activity->opportunity->crm_provider_id;\n } else {\n $objectId = $activity->prospect->crm_provider_id;\n }\n\n $noteId = $this->saveNote($title, $body, $objectId);\n\n // Store crm logged id in transcription.\n $transcription = $activity->getTranscription();\n $transcription->crm_activity_id = $noteId;\n $transcription->save();\n } catch (\\Exception $e) {\n \\Sentry::captureException($e);\n }\n }\n\n public function saveNote(string $title, string $body, string $objectId, ?NoteObject $noteObject = null): ?string\n {\n $noteId = null;\n\n try {\n if ($this->profile->log_notes === Profile::LOG_NOTE_ENHANCED) {\n $noteId = $this->buildEnhancedNote($title, $body, $objectId);\n } else {\n $noteId = $this->buildClassicNote($title, $body, $objectId);\n }\n } catch (HttpNotFoundException $exception) {\n // The profile not having access to create Enhanced Notes. Set their preference to Classic.\n if ($this->profile->log_notes === Profile::LOG_NOTE_ENHANCED) {\n $this->profile->update([\n 'log_notes' => Profile::LOG_NOTE_CLASSIC,\n ]);\n }\n }\n\n return $noteId;\n }\n\n /**\n * This is using the \"Enhanced\" Notes feature, NOT the \"Notes & Attachments\" feature being deprecated.\n *\n * @url https://salesforce.stackexchange.com/questions/104408/how-can-i-create-an-account-note-or-contact-note-via-api-that-is-visible-in-sale\n */\n private function buildEnhancedNote(string $title, string $body, string $objectId): string\n {\n // Decode stored entities, escape HTML (without quoting), then convert line breaks for Salesforce formatting\n $decodedBody = html_entity_decode($body, ENT_QUOTES | ENT_HTML5);\n $sanitizedBody = htmlspecialchars($decodedBody, ENT_NOQUOTES, 'UTF-8', false);\n $content = nl2br($sanitizedBody, false);\n $note = [\n 'OwnerId' => $this->profile->crm_provider_id,\n 'Title' => $title,\n 'Content' => base64_encode($content),\n ];\n\n $noteId = $this->createRecord('ContentNote', $note);\n\n $link = [\n 'ContentDocumentId' => $noteId,\n 'LinkedEntityId' => $objectId,\n 'ShareType' => 'I',\n ];\n\n $this->createRecord('ContentDocumentLink', $link);\n\n return $noteId;\n }\n\n private function buildClassicNote(string $title, string $body, string $objectId): string\n {\n if (in_array($this->parseObjectType($objectId), [Field::OBJECT_TASK, Field::OBJECT_EVENT])) {\n $this->logger->info('[Salesforce] Summary not sent', [\n 'profile_id' => $this->profile->id,\n 'objectId' => $objectId,\n 'reason' => 'Classical Note does not support Task/Event relation',\n ]);\n\n return '';\n }\n\n $titleTrimmed = null;\n\n if (mb_strlen($title) > 80) {\n $titleTrimmed = substr($title, 0, 77) . '...';\n }\n $payload = [\n 'OwnerId' => $this->profile->crm_provider_id,\n 'IsPrivate' => false,\n 'Title' => $titleTrimmed ?? $title,\n 'Body' => $titleTrimmed ? $title . PHP_EOL . $body : $body,\n 'ParentId' => $objectId,\n ];\n\n return $this->createRecord('Note', $payload);\n }\n\n /**\n * @inheritdoc\n */\n public function find(string $name, array $scopes): array\n {\n if ($this->profile === null) {\n return [];\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $limitValues = ['limit' => $this->limit, 'offset' => $this->offset];\n $sosl = $queryBuilder->buildFindQuery($name, $scopes, $limitValues);\n\n $this->logger->info('[Salesforce] Find prospects', [\n 'profile_id' => $this->profile->id,\n 'sosl_query' => $sosl,\n 'search_string' => $name,\n 'scopes' => $scopes,\n ]);\n\n $data = Cache::remember($this->profile->id . $sosl, self::CACHE_TTL, function () use ($sosl) {\n $data = [];\n\n try {\n // Hit remote API.\n $objects = $this->queryHandler->search($sosl);\n\n // Build mapped list.\n foreach ($objects as $object) {\n $type = strtolower($object['attributes']['type']);\n\n $record = [\n 'crmId' => $object['Id'],\n 'name' => $object['Name'],\n 'prospectType' => $type,\n 'phoneNumbers' => [],\n 'crmUrl' => $this->generateProviderUrl($object['Id'], $type),\n ];\n\n switch ($type) {\n case 'lead':\n if (empty($object['Company']) === false) {\n $record['organization'] = $object['Company'];\n }\n\n if (empty($object['Title']) === false) {\n $record['title'] = $object['Title'];\n }\n\n $stage = $this->config->stages()\n ->where('type', Stage::TYPE_LEAD)\n ->where('name', $object['Status'])\n ->first();\n\n // Lazy create the stage.\n if ($stage === null) {\n $stage = $this->importStages([Stage::TYPE_LEAD], $object['Status']);\n }\n\n if ($stage) {\n $record += [\n 'stage' => [\n 'id' => $stage->id_string,\n 'name' => $stage->name,\n ],\n ];\n }\n\n if (empty($object['RecordTypeId']) === false) {\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $object['RecordTypeId'])\n ->first();\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n }\n\n break;\n\n case 'account':\n if (empty($object['Industry']) === false) {\n $record['industry'] = $object['Industry'];\n $record['detailsLine'] = $object['Industry'];\n }\n if (! empty($object['PersonEmail'])) {\n $record['detailsLine'] = $object['PersonEmail'];\n }\n\n break;\n\n case 'contact':\n // For contacts, we should try and fetch their account name too.\n if ($object['AccountId']) {\n // Cheaper to get this locally.\n $account = $this->config->accounts()\n ->where('crm_provider_id', $object['AccountId'])\n ->first(['name']);\n\n if ($account) {\n $record['organization'] = $account->name;\n }\n }\n\n if (! empty($object['IsPersonAccount']) && $object['Email']) {\n $record['detailsLine'] = $object['Email'];\n } else {\n if (empty($object['Title']) === false) {\n $record['title'] = $object['Title'];\n }\n }\n\n break;\n }\n\n // Add phone numbers to record.\n if (empty($object['Phone']) === false && $object['Phone']) {\n $record['phoneNumbers'][] = [\n 'number' => $object['Phone'],\n 'nationalFormat' => phone_national($this->profile->user->country_code, $object['Phone']),\n 'type' => 'phone',\n ];\n }\n\n if (empty($object['MobilePhone']) === false && $object['MobilePhone']) {\n $record['phoneNumbers'][] = [\n 'number' => $object['MobilePhone'],\n 'nationalFormat' => phone_national(\n $this->profile->user->country_code,\n $object['MobilePhone']\n ),\n 'type' => 'mobile',\n ];\n }\n\n $data[] = $record;\n }\n } catch (NoResultsException $e) {\n $data = [];\n }\n\n return $data;\n });\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n\n if ($crmAccountId === null) {\n return $data;\n }\n\n if ($userId) {\n $profileRepository = app(ProfileRepository::class);\n $profile = $profileRepository->findProfileByUserId($this->config, $userId);\n\n $ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;\n }\n\n try {\n // Perhaps their profile has no opportunity permissions.\n if ($this->profile === null || $this->profile->opportunity_fields === null) {\n return $data;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $query = $queryBuilder->buildFindOpportunitiesQuery();\n\n $objects = $this->queryHandler->query($query, ['accountId' => $crmAccountId]);\n\n foreach ($objects as $object) {\n $record = [\n 'crmId' => $object['Id'],\n 'name' => $object['Name'],\n 'won' => $object['IsWon'],\n 'closed' => $object['IsClosed'],\n ];\n\n $valueFieldName = 'Amount';\n if ($this->config->opportunity_value_field_id) {\n $valueFieldName = $this->config->opportunityValueField->crm_provider_id;\n }\n\n if (empty($object[$valueFieldName]) === false) {\n $currency = $object['CurrencyIsoCode'] ?? $this->config->default_currency;\n $value = formatCurrency($object[$valueFieldName], $currency);\n\n $record += [\n 'value' => $value,\n ];\n }\n\n $stage = $this->config->stages()\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->where('name', $object['StageName'])\n ->first();\n\n // Lazy create the stage.\n if ($stage === null) {\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $object['StageName']);\n }\n\n $record += [\n 'stage' => [\n 'id' => $stage->id_string,\n 'name' => $stage->name,\n ],\n ];\n\n if (empty($object['RecordTypeId']) === false) {\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $object['RecordTypeId'])\n ->first();\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n }\n\n if ($ownerId && isset($object['OwnerId']) && $object['OwnerId'] === $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n } catch (NoResultsException $e) {\n return $data;\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n public function getContactRolesFromCrm(?Carbon $since = null): array\n {\n $roles = [];\n\n if ($this->profile === null) {\n return $roles;\n }\n\n $queryBuilder = app(QueryBuilder::class, ['profile' => $this->profile]);\n\n $query = $queryBuilder->buildGetContactRolesQuery($since);\n\n try {\n $objects = $this->queryHandler->query($query);\n\n foreach ($objects as $object) {\n $roles[] = [\n 'id' => $object['Id'],\n 'contactId' => $object['ContactId'],\n 'opportunityId' => $object['OpportunityId'],\n 'ownerId' => $object['Opportunity']['OwnerId'] ?? null,\n 'isPrimary' => $object['IsPrimary'],\n 'role' => $object['Role'],\n ];\n }\n } catch (NoResultsException) {\n // Just return an empty array.\n $this->logger->info('[Salesforce] No contact roles found', [\n 'since' => $since?->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n }\n\n return $roles;\n }\n\n public function syncContactRoles(Carbon $since): int\n {\n $contactRoleRepository = app(ContactRoleRepository::class);\n\n $crmContactRoles = $this->getContactRolesFromCrm(since: $since);\n $syncCount = 0;\n $contactRoles = [];\n\n foreach ($crmContactRoles as $crmContactRole) {\n $contactRoles[] = $this->importContactRole($crmContactRole);\n $syncCount++;\n }\n\n $contactRoleRepository->saveContactRoles($contactRoles);\n\n $this->syncRemotelyDeletedContactRoles();\n\n return $syncCount;\n }\n\n private function importContactRole(array $contactRole): array\n {\n $contact = $this->config->contacts()\n ->where('crm_provider_id', $contactRole['contactId'])\n ->first();\n\n if ($contact === null) {\n $contact = $this->syncContact($contactRole['contactId']);\n }\n\n $opportunity = $this->config->opportunities()\n ->where('crm_provider_id', $contactRole['opportunityId'])\n ->first();\n\n if ($opportunity === null) {\n $opportunity = $this->syncOpportunity($contactRole['opportunityId']);\n }\n\n $role = null;\n if (! empty($contactRole['role'])) {\n $role = mb_strimwidth($contactRole['role'], 0, 191);\n }\n\n return [\n 'crm_configuration_id' => $this->config->getId(),\n 'contact_id' => $contact->getId(),\n 'crm_provider_id' => $contactRole['id'],\n 'subject_type' => ContactRole::SUBJECT_TYPE_OPPORTUNITY,\n 'subject_id' => $opportunity->getId(),\n 'is_primary' => $contactRole['isPrimary'],\n 'role' => $role,\n ];\n }\n\n protected function syncRemotelyDeletedContactRoles(): bool\n {\n try {\n $deletedRemotely = $this->queryHandler->queryDeleted('OpportunityContactRole');\n } catch (NoResultsException $e) {\n return false;\n }\n\n $deletedOpportunities = $deletedRemotely->getResults();\n $deletedIds = array_column($deletedOpportunities, 'id');\n\n $contactRoleRepository = app(ContactRoleRepository::class);\n\n foreach (array_chunk($deletedIds, self::HARD_DELETE_CHUNK) as $chunk) {\n $contactRoleRepository->deleteContactRoles($chunk);\n\n $this->logger->info('[' . $this->getDisplayName() . '] Remotely deleted opportunities synced', [\n 'teamId' => $this->team->id_string,\n 'remotelyDeletedOpportunities' => $chunk,\n 'count' => count($chunk),\n ]);\n }\n\n return true;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = $objects = [];\n\n $hasWho = \\in_array($objectType, ['lead', 'contact']);\n $playbook = $this->getPlaybook($this->profile->user);\n $fields = array_merge(\n $this->profile->getFieldsAsArray(parent::OBJECT_TASK),\n $playbook && $playbook->activityField ? [$playbook->activityField->crm_provider_id] : []\n );\n\n // Query should default to any open call for that user.\n $query = '\n SELECT ' . implode(',', array_unique($fields)) . '\n FROM Task\n WHERE OwnerId = :ownerId\n AND IsArchived = false\n AND IsDeleted = false\n AND IsClosed = false\n AND (';\n\n if ($objectType === 'account') {\n // This covers tasks tied to a related contact or opportunity too.\n $query .= '\n AccountId = :accountId';\n }\n\n if ($hasWho) {\n $query .= '\n WhoId = :whoId';\n\n // If we are also going to check on a specific opportunity, set that up.\n if ($opportunityId) {\n $query .= ' OR WhatId = :whatId';\n }\n }\n\n $query .= ' ) ORDER BY LastModifiedDate DESC';\n\n try {\n $objects = $this->queryHandler->query($query, [\n 'ownerId' => $this->profile->crm_provider_id,\n 'whoId' => $objectId,\n 'whatId' => $opportunityId,\n 'accountId' => $objectId,\n ]);\n } catch (NoResultsException $e) {\n return $data;\n } finally {\n $this->logger->debug(sprintf('[Salesforce] Found %s tasks for query \"%s\"', count($objects), $query));\n }\n\n foreach ($objects as $object) {\n $dueDate = $object['ActivityDate'] ? Carbon::parse($object['ActivityDate'])->toIso8601String() : null;\n $data[] = [\n 'crmId' => $object['Id'],\n 'subject' => $object['Subject'],\n 'due' => $dueDate,\n 'type' => $object[$playbook->activityField->crm_provider_id],\n ];\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getEvents(string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = $objects = [];\n $user = $this->profile?->user;\n if ($this->profile === null || $user === null) {\n return $data;\n }\n\n $hasWho = \\in_array($objectType, ['lead', 'contact']);\n $playbook = $this->getPlaybook($user);\n $fields = array_merge(\n $this->profile->getFieldsAsArray(parent::OBJECT_EVENT),\n $playbook && $playbook->activityField ? [$playbook->activityField->crm_provider_id] : []\n );\n\n // Query should default to any event starting in the last week and ending up until today owned by the user.\n $query = '\n SELECT ' . implode(',', array_unique($fields)) . '\n FROM Event\n WHERE OwnerId = :ownerId\n AND IsArchived = false\n AND IsAllDayEvent = false\n AND StartDateTime >= LAST_N_DAYS:7\n AND EndDateTime <= TODAY\n AND (';\n\n if ($objectType === 'account') {\n // This covers events tied to a related contact or opportunity too.\n $query .= '\n AccountId = :accountId';\n }\n\n if ($hasWho) {\n $query .= '\n WhoId = :whoId';\n\n // If we are also going to check on a specific opportunity, set that up.\n if ($opportunityId) {\n $query .= ' OR WhatId = :whatId';\n }\n }\n\n $query .= ' ) ORDER BY LastModifiedDate DESC';\n\n try {\n $objects = $this->queryHandler->query($query, [\n 'ownerId' => $this->profile->crm_provider_id,\n 'whoId' => $objectId,\n 'whatId' => $opportunityId,\n 'accountId' => $objectId,\n ]);\n } catch (NoResultsException $e) {\n return $data;\n } finally {\n $this->logger->debug(sprintf('[Salesforce] Found %s tasks for query \"%s\"', count($objects), $query));\n }\n\n foreach ($objects as $object) {\n $dueDate = $object['StartDateTime'] ? Carbon::parse($object['StartDateTime'])->toIso8601String() : null;\n\n $data[] = [\n 'crmId' => $object['Id'],\n 'subject' => $object['Subject'],\n 'due' => $dueDate,\n 'type' => $object[$playbook->activityField->crm_provider_id],\n ];\n }\n\n return $data;\n }\n\n /**\n * Try to find CRM Objects using email address\n *\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchExactlyByEmail(string $email, ?int $userId = null): ?array\n {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $sosl = $queryBuilder->buildMatchByQuery($email, Field::TYPE_EMAIL);\n if ($sosl === null) {\n return null;\n }\n\n try {\n $objects = $this->queryHandler->search($sosl);\n $objects = $this->queryHandler->prioritiseResults(\n $objects,\n $email,\n QueryHandler::PRIORITISE_EMAIL\n );\n\n $data = $this->convertCrmData($objects, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (NoResultsException $e) {\n // Try the account next.\n if ($this->profile->account_fields === null) {\n return null;\n }\n }\n\n return null;\n }\n\n public function getDomain(string $email): ?string\n {\n // SF improved search - strip the domain extension, min domain name length 4\n return $this->getCompanyNameFromEmail(email: $email, minNameLength: 4);\n }\n\n /**\n * Try to find CRM objects using domain name of the email address\n *\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByDomain(string $domain, ?int $userId = null): ?array\n {\n $companyName = $domain;\n\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $sosl = $queryBuilder->buildMatchByDomainQuery($companyName);\n\n try {\n $objects = $this->queryHandler->search($sosl);\n\n $data = $this->convertCrmData($objects, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (NoResultsException) {\n return null;\n }\n }\n\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n // Don't bother looking up numbers that are masked.\n if (str_contains($phone, '**')) {\n return null;\n }\n\n if ($this->isPhoneNumberOfTeamMember($phone)) {\n return null;\n }\n\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $phoneNational = phone_national(null, $phone) ?? '';\n $possiblePhoneFormats = collect([\n preg_replace('/\\D/', '', ltrim($phone, '0+')),\n preg_replace('/\\D/', '', $phoneNational),\n formatDashPhoneNumber($phone),\n $phoneNational,\n ])\n ->filter() // Removes null and empty strings\n ->unique()\n ->values();\n\n foreach ($possiblePhoneFormats as $phone) {\n $sosl = $queryBuilder->buildMatchByQuery($phone, Field::TYPE_PHONE);\n if ($sosl === null) {\n continue;\n }\n\n try {\n $objects = $this->queryHandler->search($sosl);\n $objects = $this->queryHandler->prioritiseResults(\n $objects,\n $phone,\n QueryHandler::PRIORITISE_PHONE\n );\n\n return $this->convertCrmData($objects, $userId);\n } catch (NoResultsException) {\n continue;\n }\n }\n\n return null;\n }\n\n private function isPhoneNumberOfTeamMember(string $phone): bool\n {\n $teamRepository = app(TeamRepository::class);\n $user = $teamRepository->findTeamMemberByPhone($this->team, $phone);\n\n if ($user instanceof User) {\n return true;\n }\n\n return false;\n }\n\n protected function getCacheKey(string $object, ?int $userId = null): ?string\n {\n $key = $this->profile->id . $object;\n $keySuffix = $this->getOwnerKeySuffix($userId);\n\n return $key . $keySuffix;\n }\n\n private function getOwnerKeySuffix(?int $userId = null): string\n {\n return $userId === null ? '' : (string) $userId;\n }\n\n /** Determine the CRM Objects which represent the call activity. */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n if ($this->profile === null) {\n return null;\n }\n\n $cacheKey = $this->getCacheKey($name, $userId);\n\n $result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $sosl = $queryBuilder->buildMatchByQuery($name, 'name');\n if ($sosl === null) {\n return false;\n }\n\n try {\n $objects = $this->queryHandler->search($sosl);\n } catch (NoResultsException $e) {\n return false;\n }\n\n $objects = $this->queryHandler->prioritiseResults(\n $objects,\n $name,\n QueryHandler::PRIORITISE_NAME\n );\n\n $data = $this->convertCrmData($objects, $userId);\n\n return (! empty(array_filter($data))) ? $data : false;\n });\n\n return is_array($result) ? $result : null;\n }\n\n /**\n * @return array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n protected function convertCrmData(QueryIterator $objects, ?int $userId = null): array\n {\n $lead = null;\n $contact = null;\n $opportunity = null;\n $account = null;\n $stage = null;\n $countryCode = null;\n\n if ($objects->count() > 0) {\n $object = $objects->current();\n\n if ($object['attributes']['type'] === 'Lead') {\n $lead = $this->importLead($object);\n\n // Lead might not be imported if the Stage is null for example.\n if ($lead) {\n $countryCode = $lead->country_code;\n $stage = $lead->stage;\n }\n } else {\n if ($object['attributes']['type'] === 'Contact') {\n $contact = $this->importContact($object);\n $account = $contact->account;\n } else {\n $account = $this->importAccount($object);\n }\n\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account) {\n $countryCode = $account->country_code;\n }\n\n try {\n $sfOpportunities = $this->findOpportunities(\n $account?->getCrmProviderId(),\n $contact?->getCrmProviderId(),\n $userId\n );\n\n // Take the first opportunity, which will be ordered as priority based on their settings.\n if (! empty($sfOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($sfOpportunities[0]['crmId']);\n $stage = $opportunity?->stage;\n }\n } catch (Exception) {\n // Nothing to see here.\n }\n }\n }\n\n return [\n $lead,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n /**\n * @inheritdoc\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n if ($stage->type === Stage::TYPE_LEAD) {\n $objectType = 'Lead';\n $objectId = $crmObject->crm_provider_id;\n $objectStageType = 'Status';\n } else {\n $objectType = 'Opportunity';\n $objectId = $crmObject->crm_provider_id;\n $objectStageType = 'StageName';\n }\n\n $headers = [];\n if ($this->config->trigger_assignment_rules === false) {\n // @see: https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/headers_autoassign.htm\n $headers = [\n 'Sforce-Auto-Assign' => 'false',\n ];\n }\n\n $this->updateRecord($objectType, $objectId, [$objectStageType => $stage->name], $headers);\n }\n\n public function parseObjectType(string $objectId): string\n {\n if (Str::startsWith($objectId, '001')) {\n return 'account';\n }\n\n if (Str::startsWith($objectId, '003')) {\n return 'contact';\n }\n\n if (Str::startsWith($objectId, '00Q')) {\n return 'lead';\n }\n\n if (Str::startsWith($objectId, '006')) {\n return 'opportunity';\n }\n\n if (Str::startsWith($objectId, '00U')) {\n return 'event';\n }\n\n if (Str::startsWith($objectId, '00T')) {\n return 'task';\n }\n\n throw new \\InvalidArgumentException('Unsupported Object Type');\n }\n\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, ['profile' => $this->profile]);\n $query = $queryBuilder->buildGetUsersQuery($userToSearch);\n\n try {\n $salesforceUsers = $this->queryHandler->query($query, [\n 'active' => true,\n ]);\n } catch (NoResultsException $e) {\n $this->logger->info('[Salesforce] Sync Profiles. No users found', [\n 'query' => $query,\n 'error' => $e->getMessage(),\n ]);\n\n return null;\n }\n\n $teamRepository = app(TeamRepository::class);\n $customRules = $this->getCustomProfileRules($teamRepository);\n\n foreach ($salesforceUsers as $crmUser) {\n if ($crmUser['Email'] === null) {\n continue;\n }\n\n if (! $this->customProfileValidation($crmUser, $customRules)) {\n continue;\n }\n\n $user = $teamRepository->findActiveTeamMemberByEmail($this->team, $crmUser['Email']);\n\n if (! $user instanceof User) {\n continue;\n }\n\n $edition = $crmUser['UserPreferencesLightningExperiencePreferred']\n ? Profile::EDITION_LIGHTNING\n : Profile::EDITION_CLASSIC;\n\n $profileRepository = app(ProfileRepository::class);\n $profile = $profileRepository->updateOrCreateProfile(\n $user,\n [\n 'crm_configuration_id' => $this->config->getId(),\n 'crm_provider_id' => $crmUser['Id'],\n ],\n [\n 'user_id' => $user->getId(),\n 'edition' => $edition,\n 'has_external_cti' => ! empty($crmUser['CallCenterId']),\n 'crm_profile_id' => $crmUser['ProfileId'],\n ]\n );\n\n if ($userToSearch instanceof User && $userToSearch->getId() === $user->getId()) {\n return $profile;\n }\n }\n\n // Clean up inactive profiles\n try {\n $this->archiveInactiveProfiles();\n } catch (\\Exception $e) {\n $this->logger->warning('[Salesforce] Profile archiving failed', [\n 'teamId' => $this->team->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $url = null;\n\n // For Salesforce it's easy, we just point every object to the apex domain and they handle it.\n switch ($objectType) {\n case 'lead':\n case 'account':\n case 'contact':\n case 'opportunity':\n case 'task':\n case 'event':\n case 'activity':\n\n $url = $this->config->crm_base_url . '/' . $providerId;\n\n break;\n }\n\n return $url;\n }\n\n public function buildTaskSearchFields(): array\n {\n return ['Id', 'WhoId', 'WhatId', 'AccountId'];\n }\n\n public function getTaskByFilterConditions(\n array $fields,\n array $filters,\n bool $bulkSearch = false,\n bool $strictFilters = true\n ): ?array {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $query = $queryBuilder->buildSearchTaskQuery($fields, $filters, $bulkSearch, $strictFilters);\n\n try {\n if (! $bulkSearch) {\n $objects = $this->queryHandler->query($query, $filters);\n if ($objects->count() === 1) {\n return $objects->current();\n }\n }\n\n if ($bulkSearch) {\n $objects = $this->queryHandler->query($query);\n $records = [];\n foreach ($objects as $record) {\n $key = $record[end($fields)];\n $records[$key] = $record;\n }\n\n return $records;\n }\n } catch (\\Exception $e) {\n $this->logger->info('[Salesforce] Failed to execute query', [\n 'query' => $query,\n 'error' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function mapCrmObjects(array $task): array\n {\n $activityData = [];\n\n if (! empty($task['WhoId'])) {\n $type = $this->parseObjectType($task['WhoId']);\n $activityData[$type] = $task['WhoId'];\n }\n if (! empty($task['AccountId'])) {\n $activityData['account'] = $task['AccountId'];\n }\n if (! empty($task['WhatId'])) {\n $activityData['opportunity'] = $task['WhatId'];\n }\n\n return $activityData;\n }\n\n /**\n * Get SF task by Outreach call id.\n */\n public function getTaskByFilter(\n string $activityFieldType,\n array $filters,\n string $operator = '=',\n array $additionalFields = []\n ): ?array {\n $data = [];\n\n try {\n // Default (base) fields.\n $fields = ['Id', 'Subject', 'Description', 'ActivityDate', 'WhoId', 'WhatId', $activityFieldType];\n\n foreach ($additionalFields as $additionalField) {\n $fields[] = $additionalField->crm_provider_id;\n }\n\n $fields = array_unique($fields);\n\n // Find task with the same Outreach id as the call id.\n $query = 'SELECT ' . implode(',', $fields) . '\n FROM Task\n WHERE IsArchived = false AND IsDeleted = false';\n\n foreach ($filters as $key => $value) {\n $key = preg_quote($key, '/');\n $key = str_replace(['\\'', '\"'], '', $key);\n // Prepare the substitution.\n $strKey = \":$key\";\n\n $query .= \" AND $key $operator $strKey\";\n }\n\n $query .= ' ORDER BY LastModifiedDate DESC LIMIT 1';\n\n $objects = $this->queryHandler->query($query, $filters);\n\n // There should be only one task related to this call if any.\n if ($objects->count() === 1) {\n $object = $objects->current();\n\n $dueDate = $object['ActivityDate'] ? Carbon::parse($object['ActivityDate'])->toIso8601String() : null;\n\n $data = array_merge($object, [\n 'crmId' => $object['Id'],\n 'subject' => $object['Subject'],\n 'summary' => $object['Description'],\n 'due' => $dueDate,\n 'Type' => $object[$activityFieldType],\n ]);\n }\n } catch (NoResultsException $e) {\n // Filters don't match any records.\n } catch (ServiceUnavailableException $serviceUnavailableException) {\n // Service cannot be queried. We should probably log this.\n }\n\n return $data;\n }\n\n /**\n * Get Salesforce fields including datetime fields\n *\n * @param $objectType\n */\n private function getAllFieldsAsArray($objectType): array\n {\n $basicFields = [];\n // Not all users have access to all object fields.\n if ($this->profile->{$objectType . '_fields'}) {\n $basicFields = explode(',', $this->profile->{$objectType . '_fields'});\n }\n\n $extraFields = [\n 'CreatedDate',\n 'LastModifiedDate',\n 'IsDeleted',\n ];\n\n if ($objectType === self::OBJECT_OPPORTUNITY\n && $this->config->opportunity_value_field_id\n && ! in_array($this->config->opportunityValueField->crm_provider_id, $basicFields)\n ) {\n $extraFields[] = $this->config->opportunityValueField->crm_provider_id;\n }\n\n return array_unique(array_merge($basicFields, $extraFields));\n }\n\n /**\n * Generate transcription for activity description.\n */\n private function generateTranscription(Activity $activity): string\n {\n if (! ($this->config->store_transcript)) {\n // If sending transcription to activity toggle is disabled\n return '';\n }\n\n return $this->transcriptionService\n ->findTranscriptionByActivity($activity)\n ->map(static function (array $transcriptionSegment): string {\n return $transcriptionSegment['formattedStartsAt'] . ' | ' . $transcriptionSegment['transcript'];\n })\n ->implode(PHP_EOL);\n }\n\n /**\n * Find related Salesforce event based on activity data\n *\n * @return array<string>\n */\n public function fetchRelatedActivity(Activity $activity): array\n {\n $this->logger->info('[Salesforce] Searching for related activity', [\n 'activityId' => $activity->getUuid(),\n 'ownerId' => $this->profile?->crm_provider_id,\n ]);\n\n $sfEvent = $this->fetchRelatedEvent($activity);\n if (empty($sfEvent)) {\n $this->logger->info('[Salesforce] No related activity found', [\n 'activityId' => $activity->getUuid(),\n 'ownerId' => $this->profile?->crm_provider_id,\n 'account' => $activity->hasAccount()\n ? $activity->getAccount()->getCrmProviderId()\n : null,\n ]);\n\n return [];\n }\n\n return $sfEvent;\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n if ($activity->isTypeConference() === false) {\n return null;\n }\n\n if ($activity->hasActualStartTime() === false && $activity->hasScheduledStartTime() === false) {\n return null;\n }\n\n if (! $activity->hasProspect()) {\n $this->logger->info('[Salesforce] Skip look up, Activity not linked to Lead, Contact or Account', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n $playbook = $this->getPlaybook($activity->getUser());\n if ($playbook !== null && $playbook->getActivityType() === Playbook::ACTIVITY_TYPE_TASK) {\n $this->logger->info('[Salesforce] Skip auto-sync for task-based playbook', [\n 'activityUuid' => $activity->getUuid(),\n 'playbookId' => $playbook->getId(),\n 'playbookType' => $playbook->getActivityType(),\n ]);\n\n return null;\n }\n\n try {\n $sfEvent = $this->fetchRelatedActivity($activity);\n if (empty($sfEvent)) {\n return null;\n }\n\n [$activityField, $activityType] = $this->resolveActivityTypeFromEvent($activity, $sfEvent);\n\n $this->logger->info('[Salesforce] Found related activity', [\n 'activityId' => $activity->getUuid(),\n 'sfEvent' => $sfEvent['Id'],\n 'activityFieldName' => $activityField,\n 'crmActivityType' => ($activityField !== null && isset($sfEvent[$activityField]))\n ? $sfEvent[$activityField]\n : null,\n 'activityType' => $activityType,\n ]);\n\n $userId = $this->findRelatedActivityUserId($activity, $sfEvent);\n\n if ($activity->getUserId() !== $userId) {\n $this->logger->info('[Salesforce] Updating meeting owner', [\n 'activityId' => $activity->getUuid(),\n 'oldUserId' => $activity->getUserId(),\n 'newUserId' => $userId,\n ]);\n }\n\n $this->updateSfEventDescription($activity, $sfEvent);\n\n $activity->update([\n 'user_id' => $userId,\n 'crm_provider_id' => $sfEvent['Id'],\n 'playbook_category_id' => $activityType->id ?? $activity->getCategory()?->getId(),\n ]);\n\n $this->logger->info('[Salesforce] Activity updated', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return $activity;\n } catch (\\Exception $exception) {\n \\Sentry::captureException($exception);\n\n throw $exception;\n }\n }\n\n /**\n * @param array<string, mixed> $sfEvent\n *\n * @return array{0: string|null, 1: mixed}\n */\n private function resolveActivityTypeFromEvent(Activity $activity, array $sfEvent): array\n {\n $activityField = $this->getActivityFieldName($activity);\n $activityType = null;\n\n if ($activityField !== null && ! empty($sfEvent[$activityField])) {\n $playbook = $this->getPlaybook($activity->getUser());\n $activityType = $this->getPlaybookCategory($playbook, strval($sfEvent[$activityField]));\n }\n\n return [$activityField, $activityType];\n }\n\n /**\n * @param array<string> $sfEvent\n */\n private function findRelatedActivityUserId(Activity $activity, array $sfEvent): int\n {\n $userId = $activity->getUserId();\n\n if (empty($sfEvent['OwnerId']) === false) {\n $profile = $this\n ->config\n ->profiles()\n ->where('crm_provider_id', $sfEvent['OwnerId'])\n ->get()\n ->filter(static function (Profile $profile) use ($activity): bool {\n if (! $activity->isTypeConference()) {\n return ! empty($profile->user) ? $profile->user->isStatusActive() : false;\n }\n\n $participants = $activity->getParticipants();\n\n return ! empty($profile->user)\n ? $profile->user->isStatusActive()\n && $profile->user->hasPermission(PermissionEnum::RECORD_MEETING)\n && $participants->contains('user_id', $profile->user_id)\n : false;\n })\n ->first();\n\n if ($profile) {\n $userId = $profile->user_id;\n }\n }\n\n return $userId;\n }\n\n /**\n * @param array<string, mixed> $sfEvent\n */\n private function updateSfEventDescription(Activity $activity, array $sfEvent): void\n {\n try {\n if (str_contains($sfEvent['Description'], $activity->id_string)) {\n return;\n }\n\n $payload = [\n 'Description' => $sfEvent['Description']\n . PHP_EOL\n . PHP_EOL\n . (new DecorateActivity())->generateDescription($activity),\n ];\n\n $this->logger->info('[Salesforce] Update record', [\n 'activityId' => $activity->getUuid(),\n 'sfEvent' => $sfEvent['Id'],\n 'payload' => $payload,\n ]);\n\n $payload = array_merge(\n $payload,\n $this->payloadBuilder->fetchCustomFieldData($activity, Field::OBJECT_EVENT)\n );\n\n $this->updateRecord('Event', $sfEvent['Id'], $payload);\n } catch (\\Exception) {\n $this->logger->error('[Salesforce] Failed to update record', [\n 'activityUuid' => $activity->getUuid(),\n 'sfEvent' => $sfEvent['Id'],\n ]);\n }\n }\n\n /**\n * Returns the most recently modified Event within time range (if any).\n *\n * @return array|null An Event record from Salesforce.\n */\n private function fetchRelatedEvent(Activity $activity): ?array\n {\n $ownerId = $this->profile?->crm_provider_id;\n if ($ownerId === null) {\n return [];\n }\n\n /** @var ?Carbon $from */\n /** @var ?Carbon $to */\n [$from, $to] = $this->getFromToDates($activity);\n\n try {\n $whoId = null;\n $hasWho = $activity->lead_id || $activity->contact_id;\n if ($hasWho) {\n $whoId = $activity->hasLead()\n ? $activity->getLead()->crm_provider_id\n : $activity->getContact()->crm_provider_id;\n }\n\n if ($hasWho === false && $activity->account_id === null) {\n return null;\n }\n\n $query = $this->buildFetchRelatedEventQuery($activity);\n\n $objects = $this->queryHandler->query($query, [\n 'ownerId' => $ownerId,\n 'whoId' => $whoId,\n 'whatId' => $activity->hasOpportunity() ? $activity->getOpportunity()->crm_provider_id : null,\n 'accountId' => $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null,\n 'from' => $from?->format('Y-m-d\\TH:i:s\\Z'),\n 'to' => $to?->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($objects as $object) {\n return $object;\n }\n } catch (NoResultsException $e) {\n return [];\n }\n\n return [];\n }\n\n private function getFromToDates(Activity $activity): array\n {\n $from = null;\n $to = null;\n\n /** @var ?CalendarEvent $calendarEvent */\n $calendarEvent = $activity->calendarEvent()->first();\n if ($calendarEvent !== null) {\n $from = $calendarEvent->getStartTime();\n $to = $calendarEvent->getEndTime();\n }\n\n // For non-calendar imported activities\n // Also double check if calendar event dates could be null?\n // If null use what we've got so far\n if ($from === null || $to === null) {\n $from = $activity->hasScheduledStartTime()\n ? $activity->getScheduledStartTime()\n : $activity->getActualStartTime();\n $to = $activity->hasScheduledEndTime()\n ? $activity->getScheduledEndTime()->addMinutes(15)\n : $activity->getActualEndTime();\n }\n\n return [$from, $to];\n }\n\n /**\n * Determines the appropriate activity field name for querying Salesforce events.\n *\n * This method follows a hierarchy to determine the field name:\n * 1. Uses the playbook's activity field if it exists and is in the profile's accessible fields\n * 2. Falls back to the default activity field if the profile has no event fields configured\n * 3. Returns null if no suitable field is found\n *\n * @param Activity $activity The activity to determine the field for\n *\n * @return string|null The field name to use in queries, or null if none is available\n */\n private function getActivityFieldName(Activity $activity): ?string\n {\n if ($this->profile === null) {\n $this->logger->warning('[Salesforce] Cannot determine activity field - profile not found', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n $profileEventFields = $this->profile->getFieldsAsArray('event');\n\n if (empty($profileEventFields)) {\n $defaultActivityField = $this->getDefaultActivityField(Field::OBJECT_EVENT);\n $defaultFieldName = $defaultActivityField?->getAttribute('crm_provider_id');\n // Profile not yet synced — fall back to the default activity field.\n // There is a small chance that the profile won't have Default Activity Type field access\n // in which case the query will fail.\n // This is however an edge case and should be reviewed for profile sync issues.\n Sentry::withScope(function (\\Sentry\\State\\Scope $scope) use ($defaultFieldName): void {\n $scope->setContext('details', [\n 'profileId' => $this->profile->id,\n 'defaultField' => $defaultFieldName,\n ]);\n Sentry::captureMessage(\n '[Salesforce] Profile event fields empty, falling back to default activity field.',\n \\Sentry\\Severity::warning()\n );\n });\n\n return $defaultFieldName;\n }\n\n $playbook = $this->getPlaybook($activity->getUser());\n\n if (! is_null($playbook) && ! is_null($playbook->getActivityField())) {\n $playbookFieldName = $playbook->getActivityField()->getAttribute('crm_provider_id');\n\n if (in_array($playbookFieldName, $profileEventFields, true)) {\n return $playbookFieldName;\n }\n\n $this->logger->warning('[Salesforce] Playbook activity field not found in profile fields', [\n 'activityId' => $activity->getUuid(),\n 'playbookField' => $playbookFieldName,\n 'profileId' => $this->profile->id,\n ]);\n }\n\n return null;\n }\n\n private function buildFetchRelatedEventQuery(Activity $activity): string\n {\n $hasWho = $activity->lead_id || $activity->contact_id;\n\n $activityFieldName = $this->getActivityFieldName($activity);\n $fields = array_filter(['Id', 'Description', 'OwnerId', $activityFieldName]);\n\n $ownerCondition = '(OwnerId = :ownerId OR CreatedById = :ownerId)';\n\n $query = '\n SELECT ' . implode(',', $fields) . '\n FROM Event\n WHERE ' . $ownerCondition . '\n AND IsArchived = false\n AND IsAllDayEvent = false\n AND StartDateTime >= :from\n AND EndDateTime <= :to\n AND (';\n\n $operator = '';\n if ($activity->account_id) {\n // This covers events tied to a related contact or opportunity too.\n $query .= 'AccountId = :accountId';\n\n $operator = ' OR ';\n }\n\n if ($hasWho) {\n $query .= $operator . 'WhoId = :whoId';\n\n // If we are also going to check on a specific opportunity, set that up.\n if ($activity->opportunity_id) {\n $query .= ' OR WhatId = :whatId';\n }\n }\n\n $query .= ') ORDER BY LastModifiedDate DESC';\n\n return $query;\n }\n\n public function fetchProspect(array $task): array\n {\n $lead = $account = $opportunity = $contact = $stage = $countryCode = null;\n $externalId = $task['WhoId'] ?? null;\n\n // Lead or Contact\n if ($externalId) {\n try {\n [$lead, $account, $opportunity, $contact, $stage, $countryCode] = $this->parseRecords($externalId);\n } catch (\\InvalidArgumentException $exception) {\n // Invalid object type.\n }\n }\n\n // If we happen to know the opportunity or account from the Task, figure that out.\n if (empty($task['WhatId']) === false) {\n // WhatId could be either Account ID or Opportunity ID.\n // If WhatId is Opportunity ID, get the opportunity and stage from the CRM.\n try {\n [, $account, $opportunity, , $stage, ] = $this->parseRecords($task['WhatId']);\n } catch (\\InvalidArgumentException $exception) {\n // Invalid object type.\n }\n }\n\n return [$lead, $account, $opportunity, $contact, $stage, $countryCode];\n }\n\n /**\n * Save activity transcription summary as note\n */\n public function saveTranscriptionSummaryAsNote(\n ActivityContract $activity,\n string $title,\n string $body,\n ?string $objectId,\n ?NoteObject $noteObject = null,\n ): ?string {\n return $this->saveNote($title, $body, (string) $objectId);\n }\n\n public function getObjectByFilterConditions(string $objectType, array $fields, array $filters): ?array\n {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $query = $queryBuilder->buildObjectSearchQuery($objectType, $fields, $filters);\n\n try {\n $objects = $this->queryHandler->query($query, $filters);\n if ($objects->count() === 1) {\n return $objects->current();\n }\n } catch (\\Exception $e) {\n $this->logger->info('[Salesforce] Failed to execute query', [\n 'query' => $query,\n 'error' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n private function getCustomProfileRules(TeamRepository $teamRepository): array\n {\n $teamSettings = $teamRepository->getTeamSetting($this->team, 'custom_profile_validation');\n\n if ($teamSettings instanceof TeamSettings && $teamSettings->getValueType() === 'array') {\n $customRules = json_decode($teamSettings->getValue(), true);\n if (is_array($customRules)) {\n return $customRules;\n }\n }\n\n return [];\n }\n\n private function customProfileValidation(array $crmUser, array $customRules): bool\n {\n foreach ($customRules as $customRule) {\n if ($crmUser[$customRule['field']] !== $customRule['value']) {\n return false;\n }\n }\n\n return true;\n }\n\n /**\n * When syncing Contact / Lead / Account / Opportunity / Stage crm entities,\n * validate and restore locally trashed objects,\n * before updating them. Objects are identified by CrmProviderId\n */\n private function restoreAnyTrashedEntity(HasMany $targetEntity, string $crmProviderId): void\n {\n $recordExists = $targetEntity->withTrashed()->where(['crm_provider_id' => $crmProviderId])->first();\n if ($recordExists && $recordExists->trashed()) {\n $recordExists->restore();\n }\n }\n\n #[\\Override] public function supportsNotes(): bool\n {\n return true;\n }\n\n private function getOwnerProfile(?string $ownerId): ?Profile\n {\n if ($ownerId === null) {\n return null;\n }\n\n return $this->config->profiles()\n ->where('crm_provider_id', $ownerId)\n ->first();\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
-4816761511843992560
|
-7851939513083163707
|
typing_pause
|
accessibility
|
NULL
|
Diff
Changelist:
Changelist:
Changes
Show Diff
Rol Diff
Changelist:
Changelist:
Changes
Show Diff
Rollback...
Refresh
Group By
Expand All
Collapse All
1 modified
JY-209
Commit Message
Commit Message History
Git
Author:
Author:
Amend commit
Sign-off commit
Create extra commit with file movements
Commit Checks
Update copyright
Reformat code
Rearrange code
Optimize imports
Cleanup
Choose profile
Check malicious dependencies
Run external formatter
Configure
Perform SonarQube for IDE analysis
Find unused view files
Run Git hooks
Advanced Commit Checks
Analyze code
Choose profile
Check TODO
Configure
After Commit
Upload files to:
Upload files to:
<None>
Always use selected server or group of servers
Previous Difference
Next Difference
Jump to Source
Compare Previous File
Compare Next File
Go to Changed File…
Side-by-side viewer
Do not ignore
Highlight words
Collapse Unchanged Fragments
Synchronize Scrolling
Disable Editing
Settings
Help
4 differences
1aee7aad
<?php
namespace Jiminny\Services\Crm\Salesforce;
use Carbon\Carbon;
use Exception;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Events\Dispatcher;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Jiminny\Component\Country\CountriesMap;
use Jiminny\Contracts\Acl\PermissionEnum;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Contracts\Services\Crm\FetchRelatedActivityInterface;
use Jiminny\Contracts\Services\Crm\ImportsBusinessProcessesInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\SalesforceBatchSyncInterface;
use Jiminny\Contracts\Services\Crm\Provider\SalesforceInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\RemoteNoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SearchTaskInterface;
use Jiminny\Contracts\Services\Crm\SendSummaryToCrmInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SupportsObjectTypeParseInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmProfileRecordTypesInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Enums\CrmObject;
use Jiminny\Events\Activities\Crm\LeadConverted;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpBadRequestException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Exceptions\NoResultsException;
use Jiminny\Exceptions\ServiceUnavailableException;
use Jiminny\Jobs\Crm\NoteObject;
use Jiminny\Jobs\Crm\MatchActivitiesToNewOpportunity;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Calendar\CalendarEvent;
use Jiminny\Models\Contact;
use Jiminny\Models\Contracts\ActivityContract;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Models\Crm\ContactRole;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Crm\RecordType;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Playbook;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\TeamSettings;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\ContactRoleRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\Crm\RecordTypeFieldValuesRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Helpers\ArrayIterator;
use Jiminny\Services\Crm\MatchDomainByEmailInterface;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Services\Crm\ResolveCompanyNameByEmailTrait;
use Jiminny\Services\Crm\Salesforce\Fields\FieldHelper;
use Jiminny\Services\Crm\Salesforce\Fields\FieldTypeConverter;
use Jiminny\Services\Crm\Salesforce\Fields\ValueNormalizer;
use Jiminny\Services\Crm\Salesforce\ServiceTraits\FollowupActivityTrait;
use Jiminny\Services\Crm\Salesforce\ServiceTraits\LogActivityTrait;
use Jiminny\Services\Crm\Salesforce\ServiceTraits\RecordManipulationsTrait;
use Jiminny\Services\Crm\Salesforce\ServiceTraits\SyncFieldsTrait;
use Jiminny\Utils\CurrencyFormatter;
use Jiminny\Utils\StringUtil;
use Ramsey\Uuid\Uuid;
use Sentry\Laravel\Facade as Sentry;
class Service extends BaseService implements
SalesforceInterface,
SalesforceBatchSyncInterface,
SyncCrmEntitiesInterface,
SyncCrmProfileRecordTypesInterface,
ImportsBusinessProcessesInterface,
RemoteEntityManipulationInterface,
FetchRelatedActivityInterface,
SendSummaryToCrmInterface,
MatchDomainByEmailInterface,
SearchTaskInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
SupportsObjectTypeParseInterface,
RemoteNoteEntityManipulationInterface,
VerifyTaskExistsInterface
{
use ResolveCompanyNameByEmailTrait;
use SyncFieldsTrait;
use DeleteObjectsTrait;
use RecordManipulationsTrait;
use ServiceTraits\BatchSyncTrait;
use FollowupActivityTrait;
use LogActivityTrait;
/**
* Note Body Limit for the Old Note-Taking Tool
*
* @var int
*/
private const int CLASSIC_NOTE_MAX_LENGTH = 32000;
/**
* Note Content Limit for the New Notes
*
* @var int
*/
private const int ENHANCED_NOTE_MAX_LENGTH = 50000000;
private const string INSTALLED_PACKAGE_ID = '033Tw0000007bKbIAI';
private const int CACHE_TTL = 600;
private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day - 86400
/**
* @var Client
*/
protected $client;
protected PayloadBuilder $payloadBuilder;
protected QueryHandler $queryHandler;
private OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
public function __construct(
Client $client,
PayloadBuilder $payloadBuilder,
protected Dispatcher $eventDispatcher,
private readonly CountriesMap $countriesMap,
private readonly ProspectPhotoPathService $prospectPhotoPathService,
) {
parent::__construct();
$this->client = $client;
$this->payloadBuilder = $payloadBuilder;
$this->queryHandler = app(QueryHandler::class, [
'client' => $this->client,
'logger' => $this->logger,
]);
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
}
public function getDisplayName(): string
{
return 'Salesforce';
}
public function getJobDelay(): int
{
return 1;
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
return $user->getSocialAccount(SocialAccount::PROVIDER_SALESFORCE);
}
public function verifyTaskExists(Activity $activity): bool
{
$crmProviderId = $activity->getCrmProviderId();
$cacheKey = "crm_task_exists:{$this->config->getId()}:$crmProviderId";
return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($activity, $crmProviderId) {
$playbook = $this->getPlaybookFromActivity($activity);
if ($playbook === null) {
$this->logger->warning('[Salesforce] Cannot verify task - no playbook found', [
'activity' => $activity->getId(),
'crm_provider_id' => $crmProviderId,
]);
return false;
}
$objectType = $playbook->getActivityType() === Playbook::ACTIVITY_TYPE_EVENT ? 'Event' : 'Task';
try {
$record = $this->getRecord($objectType, $crmProviderId, ['Id', 'IsDeleted']);
return ! empty($record) && ($record['IsDeleted'] ?? false) === false;
} catch (HttpNotFoundException|HttpBadRequestException) {
$this->logger->info('[Salesforce] Activity record not found during verification', [
'activity' => $activity->getId(),
'object_type' => $objectType,
'crm_provider_id' => $crmProviderId,
'config_id' => $this->config->getId(),
]);
return false;
}
});
}
public function query(string $queryToRun, array $parameters = []): QueryIterator
{
// Due to poorly designed external calls, this method cannot be entirely removed
return $this->queryHandler->query($queryToRun, $parameters);
}
/*=========== Organization Information ===============*/
/**
* Get a list of all the API Versions for the instance.
*
* @throws CrmException
*
* @return mixed
*
*/
public function getApiVersions()
{
$url = $this->config->crm_base_url . '/services/data';
$response = $this->client->get($url);
return json_decode($response->getBody(), true);
}
/**
* Gets the valid recordTypes for a given Salesforce Object via the describe API.
*/
private function getRecordTypes(string $crmObject): array
{
$url = $this->client->getObjectsUrl() . $crmObject . '/describe';
$response = $this->client->get($url);
$jsonResponse = json_decode($response->getBody(), true);
$fields = [];
foreach ($jsonResponse['recordTypeInfos'] as $row) {
$fields[] = ['recordTypeId' => $row['recordTypeId'], 'default' => $row['defaultRecordTypeMapping']];
}
return $fields;
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return ValueNormalizer::normalize($fieldType, $fieldValue, $internal);
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
$defaultFields = ($activityType === Playbook::ACTIVITY_TYPE_TASK)
? FieldDefinitions::defaultTaskFields()
: FieldDefinitions::defaultEventFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
// Setup the activity field as the default Type.
/** @var Field $activityField */
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'Type',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK, Playbook::ACTIVITY_TYPE_EVENT];
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldFilter = ($activityType === Playbook::ACTIVITY_TYPE_TASK)
? FieldDefinitions::taskFollowupFieldsFilter()
: FieldDefinitions::eventFollowupFieldsFilter();
foreach ($fieldFilter as $eachFilter) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $eachFilter);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
/**
* This one is now called only when ImportActivityTypes is triggered or SyncFieldMetadata executed manually
* Regular sync now uses SharedSyncFieldsTrait -> syncSingleObjectType
* Needs to be replaced later on
*/
public function syncField(Field $field): void
{
try {
if (FieldHelper::isCustomField($field)) {
$query = '
SELECT
Id, Metadata, TableEnumOrId
FROM
CustomField
WHERE
DeveloperName = :fieldName
AND
TableEnumOrId = :fieldType
AND
NamespacePrefix = :namespacePrefix';
// We need to constrain the field lookup to the object, in case it's used in multiple places.
$objectType = \in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true)
? 'activity'
: $field->object_type;
$sfFields = $this->queryHandler->metadata($query, [
'fieldName' => substr($field->crm_provider_id, 0, -\strlen('__c')),
'fieldType' => ucfirst($objectType),
// This is used to ensure we only consider the field within the org, not installed packages.
'namespacePrefix' => 'null',
]);
// There is always 1 result at this point.
$sfField = $sfFields->current();
// Sync field metadata.
$metadata = $sfField['Metadata'];
$field->description = mb_strimwidth($metadata['description'] ?? '', 0, 191);
$field->label = mb_strimwidth($metadata['label'] ?? '', 0, Field::LABEL_MAX_LENGTH);
$field->type = $this->convertFieldType($metadata['type'], $field->getEntityName());
$field->is_mandatory = ($metadata['required'] === true);
$field->length = $metadata['length'];
$field->default_value = mb_strimwidth(trim($metadata['defaultValue'] ?? '', '"'), 0, 191);
$field->save();
} else {
$query = '
SELECT
Id, DataType, DeveloperName, Label, Length, Description
FROM
FieldDefinition
WHERE
DurableId = :entityName';
$entityName = $field->getEntityName();
$sfFields = $this->queryHandler->metadata($query, [
'entityName' => $entityName,
]);
// There is always 1 result at this point.
$sfField = $sfFields->current();
$convertedType = $this->convertFieldType($sfField['DataType'], $entityName);
$label = mb_strimwidth($sfField['Label'], 0, Field::LABEL_MAX_LENGTH);
if ($field->isBusinessType()) {
$label = 'Opportunity Type';
}
$field->description = mb_strimwidth($sfField['Description'], 0, Field::DESCRIPTION_MAX_LENGTH);
$field->label = $label;
$field->type = $convertedType;
$field->length = $sfField['Length'];
$field->save();
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
}
private function convertFieldType(string $from, ?string $entityName = null): string
{
$converter = new FieldTypeConverter();
return $converter->convert($from, $entityName);
}
/**
* @inheritdoc
*/
public function importPicklistValues(Field $field): array
{
$values = [];
$fieldValues = [];
try {
if (FieldHelper::isCustomField($field)) {
$query = '
SELECT
Id, Metadata, TableEnumOrId
FROM
CustomField
WHERE
DeveloperName = :fieldName
AND
TableEnumOrId = :fieldType
AND
NamespacePrefix = :namespacePrefix';
// We need to constrain the field lookup to the object, in case it's used in multiple places.
$objectType = \in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true) ?
'activity' : $field->object_type;
$sfFields = $this->queryHandler->metadata($query, [
'fieldName' => substr($field->crm_provider_id, 0, -\strlen('__c')),
'fieldType' => ucfirst($objectType),
// This is used to ensure we only consider the field within the org, not installed packages.
'namespacePrefix' => 'null',
]);
// There is always 1 result at this point.
$sfField = $sfFields->current();
$valueSet = $sfField['Metadata']['valueSet'];
if ($valueSet['valueSetName'] === null) {
// Local picklist values can be obtained easily.
$picklistValues = $valueSet['valueSetDefinition']['value'];
} else {
// But for some fields, we just get the Global Value Picklist pointer so need to do more work.
$picklistValues = $this->importGlobalValuePicklistValues($valueSet['valueSetName']);
}
// Import all active values.
foreach ($picklistValues as $i => $sfFieldValue) {
// Setup default value.
if ($sfFieldValue['default']) {
$field->update(['default_value' => $sfFieldValue['valueName']]);
}
// This comes through as null if active (lol).
if ($sfFieldValue['isActive'] !== false) {
$values[] = [
'value' => $sfFieldValue['valueName'],
'label' => $sfFieldValue['valueName'],
'sequence' => $i,
'is_default' => $sfFieldValue['default'],
];
}
}
} else {
$objectFields = $this->getObjectFields($field->object_type);
$fieldId = $field->crm_provider_id;
// Only work with our field of interest.
$objectField = array_filter($objectFields, function ($item) use ($fieldId) {
return $item['name'] === $fieldId;
});
$objectField = array_shift($objectField);
if (empty($objectField['picklistValues']) === false) {
foreach ($objectField['picklistValues'] as $i => $sfFieldValue) {
// Skip inactive values.
if ($sfFieldValue['active'] === false) {
continue;
}
// Setup default value.
if ($sfFieldValue['defaultValue']) {
$field->update(['default_value' => $sfFieldValue['value']]);
}
$values[] = [
'value' => $sfFieldValue['value'],
'label' => $sfFieldValue['label'],
'sequence' => $i,
'is_default' => $sfFieldValue['defaultValue'],
];
}
}
}
$fieldsToPurge = $field->values()->get()->pluck('value')->toArray();
foreach ($values as $value) {
$value['value'] = substr($value['value'] ?? '', 0, 255);
$fieldValues[] = $field->values()->updateOrCreate([
'value' => $value['value'],
], $value);
// Remove this value from the ones we are going to purge.
if (($key = array_search($value['value'], $fieldsToPurge, true)) !== false) {
unset($fieldsToPurge[$key]);
}
}
// Delete the old values that are no longer used.
// Get IDs of the values to be deleted
$valuesToDelete = $field->values()->whereIn('value', $fieldsToPurge);
$valuesToDeleteIds = $valuesToDelete->pluck('id');
if (! $valuesToDeleteIds->isEmpty()) {
$recordTypeFieldValuesRepository = app(RecordTypeFieldValuesRepository::class);
$recordTypeFieldValuesRepository->deleteForCrmFieldValueIds($valuesToDeleteIds->toArray());
// Now safely delete from crm_field_values
$valuesToDelete->delete();
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
return $fieldValues;
}
/**
* Gets values from Global Value Picklists.
*/
private function importGlobalValuePicklistValues(string $picklistName): array
{
$query = '
SELECT
Metadata
FROM
GlobalValueSet
WHERE
DeveloperName = :picklistName
LIMIT 1';
try {
$sfValues = $this->queryHandler->metadata($query, [
'picklistName' => $picklistName,
]);
// There is always 1 result at this point.
$sfValue = $sfValues->current();
return $sfValue['Metadata']['customValue'];
} catch (NoResultsException $noResultsException) {
// Nothing returned.
return [];
}
}
/**
* @inheritdoc
*/
public function syncProfileRecordTypes(): void
{
$objectTypes = [
'lead',
'account',
'contact',
'opportunity',
'task',
'event',
];
foreach ($objectTypes as $objectType) {
try {
$crmRecordTypes = $this->getRecordTypes(ucfirst($objectType));
foreach ($crmRecordTypes as $crmRecordType) {
// If the record type is default and not the Master type, set this.
if ($crmRecordType['default'] && $crmRecordType['recordTypeId'] !== '012000000000000AAA') {
$recordType = $this->config->recordTypes()
->where('crm_provider_id', $crmRecordType['recordTypeId'])
->first();
if ($recordType) {
$this->profile->{$objectType . '_record_type_id'} = $recordType->id;
}
}
}
} catch (HttpNotFoundException $exception) {
Log::error('No access to ' . $objectType . ' object, skipping...');
// XXX: should we log this fact somewhere?
continue;
}
}
if ($this->profile->isDirty()) {
$this->profile->save();
}
}
/**
* Gets business processes.
*/
public function importBusinessProcesses(): void
{
$query = '
SELECT
Id, IsActive, Name, TableEnumOrId
FROM
BusinessProcess
WHERE
TableEnumOrId IN (\'Lead\',\'Opportunity\')';
try {
$sfProcesses = $this->queryHandler->query($query);
// Upsert all processes for the team.
foreach ($sfProcesses as $sfProcess) {
/** @var BusinessProcess $businessProcess */
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => $sfProcess['Id'],
], [
'team_id' => $this->team->id,
'name' => $sfProcess['Name'],
'type' => $sfProcess['TableEnumOrId'] === 'Lead' ? 'lead' : 'opportunity',
'is_selectable' => $sfProcess['IsActive'],
]);
$this->importBusinessProcessStages($businessProcess);
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
}
/**
* Gets business process stages.
*/
private function importBusinessProcessStages(BusinessProcess $businessProcess): void
{
$query = '
SELECT
Metadata
FROM
BusinessProcess
WHERE
Id = :processId';
try {
$stages = [];
$sfProcessStages = $this->queryHandler->metadata($query, [
'processId' => $businessProcess->crm_provider_id,
]);
// There is always 1 result at this point.
$sfProcessStage = $sfProcessStages->current();
// Upsert all processes for the team.
foreach ($sfProcessStage['Metadata']['values'] as $sfProcessStage) {
$sanitizedName = urldecode($sfProcessStage['valueName']); // Must decode: "%2C" becomes "," etc.
$stage = $businessProcess->crm->stages()
// This MUST match on label because this API doesn't use API Name.
->where('label', $sanitizedName)
->where('type', $businessProcess->type)
->where('is_selectable', 1)
->first();
if ($stage) {
$stages[] = $stage->id;
}
}
$businessProcess->stages()->sync($stages);
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
}
/**
* Gets record types.
*/
public function importRecordTypes(): void
{
$query = '
SELECT
Id, IsActive, Name, BusinessProcessId, SobjectType
FROM
RecordType';
try {
$sfRecordTypes = $this->queryHandler->query($query);
// Upsert all record types for the process.
foreach ($sfRecordTypes as $sfRecordType) {
$businessProcess = null;
if ($sfRecordType['BusinessProcessId']) {
$businessProcess = $this->config->businessProcesses()
->where('crm_provider_id', $sfRecordType['BusinessProcessId'])
->first();
}
/** @var RecordType $recordType */
$recordType = $this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => $sfRecordType['Id'],
], [
'team_id' => $this->team->id,
'type' => mb_strtolower($sfRecordType['SobjectType']),
'name' => $sfRecordType['Name'],
'is_selectable' => $sfRecordType['IsActive'],
'business_process_id' => $businessProcess->id ?? null,
]);
$this->importRecordTypeFieldValues($recordType);
}
} catch (NoResultsException $noResultsException) {
// Do nothing.
}
}
/**
* Import record type - field value mappings. This only works for standard fields.
*/
private function importRecordTypeFieldValues(RecordType $recordType): void
{
try {
$query = '
SELECT
Metadata
FROM
RecordType
WHERE
Id = :recordTypeId';
$sfFields = $this->queryHandler->metadata($query, [
'recordTypeId' => $recordType->crm_provider_id,
]);
// There is always 1 result at this point.
$sfField = $sfFields->current();
// Sync field metadata.
$picklists = $sfField['Metadata']['picklistValues'];
foreach ($picklists as $picklist) {
$field = $this->config->fields()->where([
'type' => Field::TYPE_PICKLIST,
'object_type' => $recordType->type,
'crm_provider_id' => $picklist['picklist'],
])->first();
if ($field) {
$fieldValues = [];
foreach ($picklist['values'] as $value) {
// Must decode: "%2C" becomes "," etc.
$fieldValue = $field->values()
->where('value', urldecode($value['valueName']))
->first();
if ($fieldValue) {
$fieldValues[] = $fieldValue->id;
}
}
$recordType->fieldValues()->sync($fieldValues);
}
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
}
/**
* @inheritdoc
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$params = [];
$missingStage = null;
if ($types === null) {
$types = [Stage::TYPE_LEAD, Stage::TYPE_OPPORTUNITY];
}
foreach ($types as $type) {
if ($type === Stage::TYPE_LEAD) {
$query = '
SELECT
Id, ApiName, MasterLabel, SortOrder
FROM
LeadStatus';
} else {
$query = '
SELECT
Id, ApiName, MasterLabel, IsActive, SortOrder, DefaultProbability
FROM
OpportunityStage';
}
if ($missingStageName) {
$escapedStageName = ValueNormalizer::replaceQueryWithStringLiterals($missingStageName);
$query .= ' WHERE ApiName = :stageName';
$params = [
'stageName' => $escapedStageName,
];
}
try {
$sfStages = $this->queryHandler->query($query, $params);
} catch (NoResultsException $exception) {
$sfStages = [];
}
$missingStage = null;
// Upsert all stages for the team.
foreach ($sfStages as $sfStage) {
$selectable = true;
if (array_key_exists('IsActive', $sfStage)) {
$selectable = $sfStage['IsActive'];
}
$this->restoreAnyTrashedEntity($this->config->stages(), $sfStage['Id']);
$stage = $this->config->stages()->updateOrCreate([
'crm_provider_id' => $sfStage['Id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($sfStage['ApiName'], 0, 50),
'label' => mb_strimwidth($sfStage['MasterLabel'], 0, 191),
'type' => $type,
'sequence' => $sfStage['SortOrder'] ?? 0,
'is_selectable' => $selectable,
'probability' => $sfStage['DefaultProbability'] ?? null,
]);
if ($missingStageName && $missingStageName === $sfStage['ApiName']) {
$missingStage = $stage;
}
}
if ($missingStageName && $missingStage === null) {
// If they requested a stage that still doesn't exist, it must be inactive so lazy create it.
$missingStage = $this->config->stages()->create([
'crm_provider_id' => Uuid::uuid4(),
'team_id' => $this->team->id,
'name' => mb_strimwidth($missingStageName, 0, 50),
'label' => mb_strimwidth($missingStageName, 0, 191),
'type' => $type,
'sequence' => 0,
'is_selectable' => 0,
]);
}
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int
{
$syncCount = 0;
$fields = $this->getAllFieldsAsArray('lead');
if (\in_array('Id', $fields, true) === false) {
return $syncCount;
}
$query = '
SELECT ' . rtrim(implode(',', $fields), ',') . '
FROM Lead
WHERE LastModifiedDate > :since
ORDER BY LastModifiedDate ASC';
try {
$sfLeads = $this->queryHandler->query($query, [
'since' => $since->format('Y-m-d\TH:i:s\Z'),
]);
foreach ($sfLeads as $sfLead) {
// Only sync if previously imported.
if ($this->hasLead($sfLead['Id'])) {
$this->importLead($sfLead);
$syncCount++;
}
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
$this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::LEAD);
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncLead(string $crmId): ?Lead
{
$fields = $this->getAllFieldsAsArray('lead');
$sfLead = $this->getRecord('Lead', $crmId, $fields);
return $this->importLead($sfLead);
}
private function importLead($crmData): ?Lead
{
/** @var ?Stage $stage */
$stage = null;
if (isset($crmData['Status'])) {
// Get the current stage.
$stage = $this->config
->stages()
->where('name', $crmData['Status'])
->where('type', Stage::TYPE_LEAD)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_LEAD], $crmData['Status']);
}
}
// If we have no way of importing this, just return null :(
if ($stage === null) {
return null;
}
$countryCode = $crmData['CountryCode'] ?? null;
// Salesforce allows custom "countries" to be created. Disregard these.
if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {
$countryCode = null;
}
// If we have no country code, try to parse it from the country name.
if ($countryCode === null && empty($crmData['Country']) !== false) {
$countryCode = $this->convertCountryNameToCode($crmData['Country']);
}
// Trim to our width and attempt to parse it.
$number = mb_strimwidth($crmData['Phone'] ?? '', 0, 25);
$parsedNumber = parsePhoneNumber($countryCode, $number);
$mobilePhone = null;
if (empty($crmData['MobilePhone']) === false) {
// Trim to our width and attempt to parse it.
$number = mb_strimwidth($crmData['MobilePhone'], 0, 25);
$mobilePhone = phone_e164($countryCode, $number);
}
$convertedDate = null;
$convertedAccount = null;
$convertedOpportunity = null;
$convertedContact = null;
if ($crmData['IsConverted'] == 'true') {
$convertedDate = $crmData['ConvertedDate'];
if (empty($crmData['ConvertedAccountId']) === false) {
$convertedAccount = $this->config
->accounts()
->where('crm_provider_id', $crmData['ConvertedAccountId'])
->first();
if ($convertedAccount === null) {
try {
$convertedAccount = $this->syncAccount($crmData['ConvertedAccountId']);
} catch (HttpNotFoundException $exception) {
// Probably the user has no permissions to access the converted data.
}
}
}
if (empty($crmData['ConvertedOpportunityId']) === false) {
$convertedOpportunity = $this->config
->opportunities()
->where('crm_provider_id', $crmData['ConvertedOpportunityId'])
->first();
if ($convertedOpportunity === null) {
try {
$convertedOpportunity = $this->syncOpportunity($crmData['ConvertedOpportunityId']);
} catch (HttpNotFoundException $exception) {
// Probably the user has no permissions to access the converted data.
}
}
}
if (empty($crmData['ConvertedContactId']) === false) {
$convertedContact = $this->team
->crm
->contacts()
->where('crm_provider_id', $crmData['ConvertedContactId'])
->first();
if ($convertedContact === null) {
try {
$convertedContact = $this->syncContact($crmData['ConvertedContactId']);
} catch (HttpNotFoundException $exception) {
// Probably the user has no permissions to access the converted data.
}
}
}
}
if (empty($crmData['Company'])) {
$company = 'Unknown';
} else {
$company = mb_strimwidth($crmData['Company'], 0, 191);
}
$domain = null;
if (empty($crmData['Website']) === false) {
$domain = mb_strimwidth($crmData['Website'], 0, 191);
$domain = StringUtil::resolveDomain($domain);
}
$createdDate = null;
if (empty($crmData['CreatedDate']) === false) {
$createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');
}
$profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);
$data = [
'team_id' => $this->team->id,
'user_id' => $profile?->user_id,
'owner_id' => $crmData['OwnerId'] ?? '',
'company' => $company,
'domain' => $domain,
'name' => $crmData['Name'] ? mb_strimwidth($crmData['Name'], 0, 191) : '',
'title' => $crmData['Title'] ? mb_strimwidth($crmData['Title'], 0, 128) : null,
'email' => $crmData['Email'] ? mb_strimwidth($crmData['Email'], 0, 80) : null,
'phone' => $parsedNumber['phone'],
'ext' => $parsedNumber['ext'] ?? null,
'mobile_phone' => $mobilePhone,
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: $crmData['Id'],
modelType: Lead::class,
fileName: $crmData['Id'],
avatarText: $crmData['Name']
),
'stage_id' => $stage->id,
'record_type_id' => null,
'converted_at' => $convertedDate,
'converted_account_id' => $convertedAccount->id ?? null,
'converted_opportunity_id' => $convertedOpportunity->id ?? null,
'converted_contact_id' => $convertedContact->id ?? null,
'country_code' => $countryCode,
'remotely_created_at' => $createdDate,
];
$this->restoreAnyTrashedEntity($this->config->leads(), $crmData['Id']);
/** @var Lead $lead */
$lead = $this->config->leads()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);
if ($lead->wasChanged('converted_at') && $lead->getConvertedAt() !== null) {
$this->eventDispatcher->dispatch(new LeadConverted($lead));
}
$this->handleObjectDeletion($lead, $crmData);
return $lead;
}
/**
* @inheritdoc
*/
public function syncAccounts(Carbon $since, ?Carbon $to = null): int
{
$syncCount = 0;
$fields = $this->getAllFieldsAsArray('account');
if (\in_array('Id', $fields, true) === false) {
return $syncCount;
}
$query = '
SELECT ' . rtrim(implode(',', $fields), ',') . '
FROM Account
WHERE LastModifiedDate > :since
ORDER BY LastModifiedDate ASC';
try {
$sfAccounts = $this->queryHandler->query($query, [
'since' => $since->format('Y-m-d\TH:i:s\Z'),
]);
foreach ($sfAccounts as $sfAccount) {
// Only sync if previously imported.
if ($this->hasAccount($sfAccount['Id'])) {
$this->importAccount($sfAccount);
$syncCount++;
}
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
$this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::ACCOUNT);
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncAccount(string $crmId): ?Account
{
$fields = $this->getAllFieldsAsArray('account');
if (! in_array('Id', $fields, true)) {
$this->logger->info('[Salesforce] Sync account cancelled. Fields are not available.', [
'crmId' => $crmId,
'userId' => $this->profile->getUserId(),
]);
return null;
}
$sfAccount = $this->getRecord('Account', $crmId, $fields);
return $this->importAccount($sfAccount);
}
private function importAccount($crmData): Account
{
$countryCode = $crmData['BillingCountryCode'] ?? $crmData['ShippingCountryCode'] ?? null;
// Salesforce allows custom "countries" to be created. Disregard these.
if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {
$countryCode = null;
}
// If we have no country code, try to parse it from the country names.
if ($countryCode === null && empty($crmData['BillingCountry']) === false) {
$countryCode = $this->convertCountryNameToCode($crmData['BillingCountry']);
}
if ($countryCode === null && empty($crmData['ShippingCountry']) === false) {
$countryCode = $this->convertCountryNameToCode($crmData['ShippingCountry']);
}
if (empty($crmData['Phone']) === false) {
// Trim to our width and attempt to parse it.
$number = mb_strimwidth($crmData['Phone'], 0, 25);
$parsedNumber = parsePhoneNumber($countryCode, $number);
} else {
$parsedNumber = [];
}
$industry = null;
if (empty($crmData['Industry']) === false) {
$industry = mb_strimwidth($crmData['Industry'], 0, 40);
}
$domain = null;
if (empty($crmData['Website']) === false) {
$domain = mb_strimwidth($crmData['Website'], 0, 191);
$domain = StringUtil::resolveDomain($domain);
}
$createdDate = null;
if (empty($crmData['CreatedDate']) === false) {
$createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');
}
$profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);
$data = [
'team_id' => $this->team->id,
'user_id' => $profile?->user_id,
'owner_id' => $crmData['OwnerId'],
'name' => mb_strimwidth($crmData['Name'], 0, 191),
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: $crmData['Id'],
modelType: Account::class,
fileName: $crmData['Id'],
avatarText: $crmData['Name']
),
'industry' => $industry,
'domain' => $domain,
'phone' => $parsedNumber['phone'] ?? null,
'ext' => $parsedNumber['ext'] ?? null,
'country_code' => $countryCode,
'remotely_created_at' => $createdDate,
];
$this->restoreAnyTrashedEntity($this->config->accounts(), $crmData['Id']);
/** @var Account $account */
$account = $this->config->accounts()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);
$this->handleObjectDeletion($account, $crmData);
return $account;
}
/**
* @inheritdoc
*/
public function syncOpportunities(array $parameters, ?string $strategy = null): int
{
$strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);
$syncCount = 0;
$logParams = $parameters;
$parameters['profile'] = $this->profile;
$logParams['user'] = $this->profile->getUserId();
if (count($strategies) > 1) {
$this->logger->warning('[' . $this->getDisplayName() . '] Multiple sync strategies used', [
'teamId' => $this->team->getUuid(),
'params' => $logParams,
'strategies_count' => count($strategies),
]);
}
foreach ($strategies as $syncStrategy) {
$name = $syncStrategy->getStrategyName();
try {
$sfOpportunities = $syncStrategy->fetchOpportunities($parameters);
$totalRecords = $sfOpportunities->count();
foreach ($sfOpportunities as $sfOpportunity) {
$this->importOpportunity($sfOpportunity);
$syncCount++;
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
$this->logger->warning('[' . $this->getDisplayName() . '] No opportunities found', [
'teamId' => $this->team->getUuid(),
'name' => $name,
'params' => $logParams,
'reason' => $noResultsException->getMessage(),
]);
} catch (CrmException $crmException) {
// Nothing to sync.
$this->logger->warning('[' . $this->getDisplayName() . '] Opportunity sync failed', [
'teamId' => $this->team->getUuid(),
'name' => $name,
'params' => $logParams,
'reason' => $crmException->getMessage(),
]);
}
}
$this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::OPPORTUNITY, ['params' => $logParams]);
// debug to see how if count of opportunities reaches 1000
if ($syncCount >= 1000) {
$this->logger->info(
'[' . $this->getDisplayName() . '] Sync Opportunities - count warning',
[
'team_id' => $this->team->getId(),
'params' => $logParams,
'count' => $syncCount,
'strategies_count' => count($strategies),
'total_records' => $totalRecords ?? null,
]
);
}
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncOpportunity(string $crmId): ?Opportunity
{
$strategy = $this->opportunitySyncStrategyResolver->resolve(
$this->config,
OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY
);
$parameters = [
'profile' => $this->profile,
'crm_id' => $crmId,
];
try {
$sfOpportunity = $strategy->fetchOpportunities($parameters);
} catch (HttpNotFoundException $e) {
$this->logger->info('[' . $this->getDisplayName() . '] Opportunity not found', [
'teamId' => $this->team->id_string,
'crmId' => $crmId,
]);
return null;
} catch (CrmException $crmException) {
$this->logger->info('[' . $this->getDisplayName() . '] Opportunity sync failed', [
'teamId' => $this->team->id_string,
'crmId' => $crmId,
'exception' => $crmException->getMessage(),
]);
return null;
}
if ($sfOpportunity instanceof ArrayIterator) {
return $this->importOpportunity($sfOpportunity->getItems());
}
return $this->importOpportunity($sfOpportunity);
}
/**
* @throws HttpNotFoundException
*/
private function importOpportunity($crmData): ?Opportunity
{
$profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);
$account = null;
if (empty($crmData['AccountId']) === false) {
/** @var ?Account $account */
$account = $this->config->accounts()
->where('crm_provider_id', (string) $crmData['AccountId'])
->first();
if ($account === null) {
$account = $this->syncAccount($crmData['AccountId']);
}
}
$userId = $profile?->getUserId() ?? $account?->getUserId();
if ($u...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
70645
|
2537
|
17
|
2026-05-22T10:26:43.567949+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779445603567_m2.jpg...
|
PhpStorm
|
Commit Changes
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Diff
Changelist:
Changelist:
Changes
Show Diff
Rol Diff
Changelist:
Changelist:
Changes
Show Diff
Rollback...
Refresh
Group By
Expand All
Collapse All
1 modified
JY-20
Commit Message
Commit Message History
Git
Author:
Author:
Amend commit
Sign-off commit
Create extra commit with file movements
Commit Checks
Update copyright
Reformat code
Rearrange code
Optimize imports
Cleanup
Choose profile
Check malicious dependencies
Run external formatter
Configure
Perform SonarQube for IDE analysis
Find unused view files
Run Git hooks
Advanced Commit Checks
Analyze code
Choose profile
Check TODO
Configure
After Commit
Upload files to:
Upload files to:
<None>
Always use selected server or group of servers
Previous Difference
Next Difference
Jump to Source
Compare Previous File
Compare Next File
Go to Changed File…
Side-by-side viewer
Do not ignore
Highlight words
Collapse Unchanged Fragments
Synchronize Scrolling
Disable Editing
Settings
Help
4 differences
1aee7aad
<?php
namespace Jiminny\Services\Crm\Salesforce;
use Carbon\Carbon;
use Exception;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Events\Dispatcher;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Jiminny\Component\Country\CountriesMap;
use Jiminny\Contracts\Acl\PermissionEnum;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Contracts\Services\Crm\FetchRelatedActivityInterface;
use Jiminny\Contracts\Services\Crm\ImportsBusinessProcessesInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\SalesforceBatchSyncInterface;
use Jiminny\Contracts\Services\Crm\Provider\SalesforceInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\RemoteNoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SearchTaskInterface;
use Jiminny\Contracts\Services\Crm\SendSummaryToCrmInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SupportsObjectTypeParseInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmProfileRecordTypesInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Enums\CrmObject;
use Jiminny\Events\Activities\Crm\LeadConverted;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpBadRequestException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Exceptions\NoResultsException;
use Jiminny\Exceptions\ServiceUnavailableException;
use Jiminny\Jobs\Crm\NoteObject;
use Jiminny\Jobs\Crm\MatchActivitiesToNewOpportunity;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Calendar\CalendarEvent;
use Jiminny\Models\Contact;
use Jiminny\Models\Contracts\ActivityContract;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Models\Crm\ContactRole;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Crm\RecordType;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Playbook;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\TeamSettings;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\ContactRoleRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\Crm\RecordTypeFieldValuesRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Helpers\ArrayIterator;
use Jiminny\Services\Crm\MatchDomainByEmailInterface;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Services\Crm\ResolveCompanyNameByEmailTrait;
use Jiminny\Services\Crm\Salesforce\Fields\FieldHelper;
use Jiminny\Services\Crm\Salesforce\Fields\FieldTypeConverter;
use Jiminny\Services\Crm\Salesforce\Fields\ValueNormalizer;
use Jiminny\Services\Crm\Salesforce\ServiceTraits\FollowupActivityTrait;
use Jiminny\Services\Crm\Salesforce\ServiceTraits\LogActivityTrait;
use Jiminny\Services\Crm\Salesforce\ServiceTraits\RecordManipulationsTrait;
use Jiminny\Services\Crm\Salesforce\ServiceTraits\SyncFieldsTrait;
use Jiminny\Utils\CurrencyFormatter;
use Jiminny\Utils\StringUtil;
use Ramsey\Uuid\Uuid;
use Sentry\Laravel\Facade as Sentry;
class Service extends BaseService implements
SalesforceInterface,
SalesforceBatchSyncInterface,
SyncCrmEntitiesInterface,
SyncCrmProfileRecordTypesInterface,
ImportsBusinessProcessesInterface,
RemoteEntityManipulationInterface,
FetchRelatedActivityInterface,
SendSummaryToCrmInterface,
MatchDomainByEmailInterface,
SearchTaskInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
SupportsObjectTypeParseInterface,
RemoteNoteEntityManipulationInterface,
VerifyTaskExistsInterface
{
use ResolveCompanyNameByEmailTrait;
use SyncFieldsTrait;
use DeleteObjectsTrait;
use RecordManipulationsTrait;
use ServiceTraits\BatchSyncTrait;
use FollowupActivityTrait;
use LogActivityTrait;
/**
* Note Body Limit for the Old Note-Taking Tool
*
* @var int
*/
private const int CLASSIC_NOTE_MAX_LENGTH = 32000;
/**
* Note Content Limit for the New Notes
*
* @var int
*/
private const int ENHANCED_NOTE_MAX_LENGTH = 50000000;
private const string INSTALLED_PACKAGE_ID = '033Tw0000007bKbIAI';
private const int CACHE_TTL = 600;
private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day - 86400
/**
* @var Client
*/
protected $client;
protected PayloadBuilder $payloadBuilder;
protected QueryHandler $queryHandler;
private OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
public function __construct(
Client $client,
PayloadBuilder $payloadBuilder,
protected Dispatcher $eventDispatcher,
private readonly CountriesMap $countriesMap,
private readonly ProspectPhotoPathService $prospectPhotoPathService,
) {
parent::__construct();
$this->client = $client;
$this->payloadBuilder = $payloadBuilder;
$this->queryHandler = app(QueryHandler::class, [
'client' => $this->client,
'logger' => $this->logger,
]);
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
}
public function getDisplayName(): string
{
return 'Salesforce';
}
public function getJobDelay(): int
{
return 1;
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
return $user->getSocialAccount(SocialAccount::PROVIDER_SALESFORCE);
}
public function verifyTaskExists(Activity $activity): bool
{
$crmProviderId = $activity->getCrmProviderId();
$cacheKey = "crm_task_exists:{$this->config->getId()}:$crmProviderId";
return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($activity, $crmProviderId) {
$playbook = $this->getPlaybookFromActivity($activity);
if ($playbook === null) {
$this->logger->warning('[Salesforce] Cannot verify task - no playbook found', [
'activity' => $activity->getId(),
'crm_provider_id' => $crmProviderId,
]);
return false;
}
$objectType = $playbook->getActivityType() === Playbook::ACTIVITY_TYPE_EVENT ? 'Event' : 'Task';
try {
$record = $this->getRecord($objectType, $crmProviderId, ['Id', 'IsDeleted']);
return ! empty($record) && ($record['IsDeleted'] ?? false) === false;
} catch (HttpNotFoundException|HttpBadRequestException) {
$this->logger->info('[Salesforce] Activity record not found during verification', [
'activity' => $activity->getId(),
'object_type' => $objectType,
'crm_provider_id' => $crmProviderId,
'config_id' => $this->config->getId(),
]);
return false;
}
});
}
public function query(string $queryToRun, array $parameters = []): QueryIterator
{
// Due to poorly designed external calls, this method cannot be entirely removed
return $this->queryHandler->query($queryToRun, $parameters);
}
/*=========== Organization Information ===============*/
/**
* Get a list of all the API Versions for the instance.
*
* @throws CrmException
*
* @return mixed
*
*/
public function getApiVersions()
{
$url = $this->config->crm_base_url . '/services/data';
$response = $this->client->get($url);
return json_decode($response->getBody(), true);
}
/**
* Gets the valid recordTypes for a given Salesforce Object via the describe API.
*/
private function getRecordTypes(string $crmObject): array
{
$url = $this->client->getObjectsUrl() . $crmObject . '/describe';
$response = $this->client->get($url);
$jsonResponse = json_decode($response->getBody(), true);
$fields = [];
foreach ($jsonResponse['recordTypeInfos'] as $row) {
$fields[] = ['recordTypeId' => $row['recordTypeId'], 'default' => $row['defaultRecordTypeMapping']];
}
return $fields;
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return ValueNormalizer::normalize($fieldType, $fieldValue, $internal);
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
$defaultFields = ($activityType === Playbook::ACTIVITY_TYPE_TASK)
? FieldDefinitions::defaultTaskFields()
: FieldDefinitions::defaultEventFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
// Setup the activity field as the default Type.
/** @var Field $activityField */
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'Type',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK, Playbook::ACTIVITY_TYPE_EVENT];
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldFilter = ($activityType === Playbook::ACTIVITY_TYPE_TASK)
? FieldDefinitions::taskFollowupFieldsFilter()
: FieldDefinitions::eventFollowupFieldsFilter();
foreach ($fieldFilter as $eachFilter) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $eachFilter);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
/**
* This one is now called only when ImportActivityTypes is triggered or SyncFieldMetadata executed manually
* Regular sync now uses SharedSyncFieldsTrait -> syncSingleObjectType
* Needs to be replaced later on
*/
public function syncField(Field $field): void
{
try {
if (FieldHelper::isCustomField($field)) {
$query = '
SELECT
Id, Metadata, TableEnumOrId
FROM
CustomField
WHERE
DeveloperName = :fieldName
AND
TableEnumOrId = :fieldType
AND
NamespacePrefix = :namespacePrefix';
// We need to constrain the field lookup to the object, in case it's used in multiple places.
$objectType = \in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true)
? 'activity'
: $field->object_type;
$sfFields = $this->queryHandler->metadata($query, [
'fieldName' => substr($field->crm_provider_id, 0, -\strlen('__c')),
'fieldType' => ucfirst($objectType),
// This is used to ensure we only consider the field within the org, not installed packages.
'namespacePrefix' => 'null',
]);
// There is always 1 result at this point.
$sfField = $sfFields->current();
// Sync field metadata.
$metadata = $sfField['Metadata'];
$field->description = mb_strimwidth($metadata['description'] ?? '', 0, 191);
$field->label = mb_strimwidth($metadata['label'] ?? '', 0, Field::LABEL_MAX_LENGTH);
$field->type = $this->convertFieldType($metadata['type'], $field->getEntityName());
$field->is_mandatory = ($metadata['required'] === true);
$field->length = $metadata['length'];
$field->default_value = mb_strimwidth(trim($metadata['defaultValue'] ?? '', '"'), 0, 191);
$field->save();
} else {
$query = '
SELECT
Id, DataType, DeveloperName, Label, Length, Description
FROM
FieldDefinition
WHERE
DurableId = :entityName';
$entityName = $field->getEntityName();
$sfFields = $this->queryHandler->metadata($query, [
'entityName' => $entityName,
]);
// There is always 1 result at this point.
$sfField = $sfFields->current();
$convertedType = $this->convertFieldType($sfField['DataType'], $entityName);
$label = mb_strimwidth($sfField['Label'], 0, Field::LABEL_MAX_LENGTH);
if ($field->isBusinessType()) {
$label = 'Opportunity Type';
}
$field->description = mb_strimwidth($sfField['Description'], 0, Field::DESCRIPTION_MAX_LENGTH);
$field->label = $label;
$field->type = $convertedType;
$field->length = $sfField['Length'];
$field->save();
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
}
private function convertFieldType(string $from, ?string $entityName = null): string
{
$converter = new FieldTypeConverter();
return $converter->convert($from, $entityName);
}
/**
* @inheritdoc
*/
public function importPicklistValues(Field $field): array
{
$values = [];
$fieldValues = [];
try {
if (FieldHelper::isCustomField($field)) {
$query = '
SELECT
Id, Metadata, TableEnumOrId
FROM
CustomField
WHERE
DeveloperName = :fieldName
AND
TableEnumOrId = :fieldType
AND
NamespacePrefix = :namespacePrefix';
// We need to constrain the field lookup to the object, in case it's used in multiple places.
$objectType = \in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true) ?
'activity' : $field->object_type;
$sfFields = $this->queryHandler->metadata($query, [
'fieldName' => substr($field->crm_provider_id, 0, -\strlen('__c')),
'fieldType' => ucfirst($objectType),
// This is used to ensure we only consider the field within the org, not installed packages.
'namespacePrefix' => 'null',
]);
// There is always 1 result at this point.
$sfField = $sfFields->current();
$valueSet = $sfField['Metadata']['valueSet'];
if ($valueSet['valueSetName'] === null) {
// Local picklist values can be obtained easily.
$picklistValues = $valueSet['valueSetDefinition']['value'];
} else {
// But for some fields, we just get the Global Value Picklist pointer so need to do more work.
$picklistValues = $this->importGlobalValuePicklistValues($valueSet['valueSetName']);
}
// Import all active values.
foreach ($picklistValues as $i => $sfFieldValue) {
// Setup default value.
if ($sfFieldValue['default']) {
$field->update(['default_value' => $sfFieldValue['valueName']]);
}
// This comes through as null if active (lol).
if ($sfFieldValue['isActive'] !== false) {
$values[] = [
'value' => $sfFieldValue['valueName'],
'label' => $sfFieldValue['valueName'],
'sequence' => $i,
'is_default' => $sfFieldValue['default'],
];
}
}
} else {
$objectFields = $this->getObjectFields($field->object_type);
$fieldId = $field->crm_provider_id;
// Only work with our field of interest.
$objectField = array_filter($objectFields, function ($item) use ($fieldId) {
return $item['name'] === $fieldId;
});
$objectField = array_shift($objectField);
if (empty($objectField['picklistValues']) === false) {
foreach ($objectField['picklistValues'] as $i => $sfFieldValue) {
// Skip inactive values.
if ($sfFieldValue['active'] === false) {
continue;
}
// Setup default value.
if ($sfFieldValue['defaultValue']) {
$field->update(['default_value' => $sfFieldValue['value']]);
}
$values[] = [
'value' => $sfFieldValue['value'],
'label' => $sfFieldValue['label'],
'sequence' => $i,
'is_default' => $sfFieldValue['defaultValue'],
];
}
}
}
$fieldsToPurge = $field->values()->get()->pluck('value')->toArray();
foreach ($values as $value) {
$value['value'] = substr($value['value'] ?? '', 0, 255);
$fieldValues[] = $field->values()->updateOrCreate([
'value' => $value['value'],
], $value);
// Remove this value from the ones we are going to purge.
if (($key = array_search($value['value'], $fieldsToPurge, true)) !== false) {
unset($fieldsToPurge[$key]);
}
}
// Delete the old values that are no longer used.
// Get IDs of the values to be deleted
$valuesToDelete = $field->values()->whereIn('value', $fieldsToPurge);
$valuesToDeleteIds = $valuesToDelete->pluck('id');
if (! $valuesToDeleteIds->isEmpty()) {
$recordTypeFieldValuesRepository = app(RecordTypeFieldValuesRepository::class);
$recordTypeFieldValuesRepository->deleteForCrmFieldValueIds($valuesToDeleteIds->toArray());
// Now safely delete from crm_field_values
$valuesToDelete->delete();
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
return $fieldValues;
}
/**
* Gets values from Global Value Picklists.
*/
private function importGlobalValuePicklistValues(string $picklistName): array
{
$query = '
SELECT
Metadata
FROM
GlobalValueSet
WHERE
DeveloperName = :picklistName
LIMIT 1';
try {
$sfValues = $this->queryHandler->metadata($query, [
'picklistName' => $picklistName,
]);
// There is always 1 result at this point.
$sfValue = $sfValues->current();
return $sfValue['Metadata']['customValue'];
} catch (NoResultsException $noResultsException) {
// Nothing returned.
return [];
}
}
/**
* @inheritdoc
*/
public function syncProfileRecordTypes(): void
{
$objectTypes = [
'lead',
'account',
'contact',
'opportunity',
'task',
'event',
];
foreach ($objectTypes as $objectType) {
try {
$crmRecordTypes = $this->getRecordTypes(ucfirst($objectType));
foreach ($crmRecordTypes as $crmRecordType) {
// If the record type is default and not the Master type, set this.
if ($crmRecordType['default'] && $crmRecordType['recordTypeId'] !== '012000000000000AAA') {
$recordType = $this->config->recordTypes()
->where('crm_provider_id', $crmRecordType['recordTypeId'])
->first();
if ($recordType) {
$this->profile->{$objectType . '_record_type_id'} = $recordType->id;
}
}
}
} catch (HttpNotFoundException $exception) {
Log::error('No access to ' . $objectType . ' object, skipping...');
// XXX: should we log this fact somewhere?
continue;
}
}
if ($this->profile->isDirty()) {
$this->profile->save();
}
}
/**
* Gets business processes.
*/
public function importBusinessProcesses(): void
{
$query = '
SELECT
Id, IsActive, Name, TableEnumOrId
FROM
BusinessProcess
WHERE
TableEnumOrId IN (\'Lead\',\'Opportunity\')';
try {
$sfProcesses = $this->queryHandler->query($query);
// Upsert all processes for the team.
foreach ($sfProcesses as $sfProcess) {
/** @var BusinessProcess $businessProcess */
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => $sfProcess['Id'],
], [
'team_id' => $this->team->id,
'name' => $sfProcess['Name'],
'type' => $sfProcess['TableEnumOrId'] === 'Lead' ? 'lead' : 'opportunity',
'is_selectable' => $sfProcess['IsActive'],
]);
$this->importBusinessProcessStages($businessProcess);
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
}
/**
* Gets business process stages.
*/
private function importBusinessProcessStages(BusinessProcess $businessProcess): void
{
$query = '
SELECT
Metadata
FROM
BusinessProcess
WHERE
Id = :processId';
try {
$stages = [];
$sfProcessStages = $this->queryHandler->metadata($query, [
'processId' => $businessProcess->crm_provider_id,
]);
// There is always 1 result at this point.
$sfProcessStage = $sfProcessStages->current();
// Upsert all processes for the team.
foreach ($sfProcessStage['Metadata']['values'] as $sfProcessStage) {
$sanitizedName = urldecode($sfProcessStage['valueName']); // Must decode: "%2C" becomes "," etc.
$stage = $businessProcess->crm->stages()
// This MUST match on label because this API doesn't use API Name.
->where('label', $sanitizedName)
->where('type', $businessProcess->type)
->where('is_selectable', 1)
->first();
if ($stage) {
$stages[] = $stage->id;
}
}
$businessProcess->stages()->sync($stages);
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
}
/**
* Gets record types.
*/
public function importRecordTypes(): void
{
$query = '
SELECT
Id, IsActive, Name, BusinessProcessId, SobjectType
FROM
RecordType';
try {
$sfRecordTypes = $this->queryHandler->query($query);
// Upsert all record types for the process.
foreach ($sfRecordTypes as $sfRecordType) {
$businessProcess = null;
if ($sfRecordType['BusinessProcessId']) {
$businessProcess = $this->config->businessProcesses()
->where('crm_provider_id', $sfRecordType['BusinessProcessId'])
->first();
}
/** @var RecordType $recordType */
$recordType = $this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => $sfRecordType['Id'],
], [
'team_id' => $this->team->id,
'type' => mb_strtolower($sfRecordType['SobjectType']),
'name' => $sfRecordType['Name'],
'is_selectable' => $sfRecordType['IsActive'],
'business_process_id' => $businessProcess->id ?? null,
]);
$this->importRecordTypeFieldValues($recordType);
}
} catch (NoResultsException $noResultsException) {
// Do nothing.
}
}
/**
* Import record type - field value mappings. This only works for standard fields.
*/
private function importRecordTypeFieldValues(RecordType $recordType): void
{
try {
$query = '
SELECT
Metadata
FROM
RecordType
WHERE
Id = :recordTypeId';
$sfFields = $this->queryHandler->metadata($query, [
'recordTypeId' => $recordType->crm_provider_id,
]);
// There is always 1 result at this point.
$sfField = $sfFields->current();
// Sync field metadata.
$picklists = $sfField['Metadata']['picklistValues'];
foreach ($picklists as $picklist) {
$field = $this->config->fields()->where([
'type' => Field::TYPE_PICKLIST,
'object_type' => $recordType->type,
'crm_provider_id' => $picklist['picklist'],
])->first();
if ($field) {
$fieldValues = [];
foreach ($picklist['values'] as $value) {
// Must decode: "%2C" becomes "," etc.
$fieldValue = $field->values()
->where('value', urldecode($value['valueName']))
->first();
if ($fieldValue) {
$fieldValues[] = $fieldValue->id;
}
}
$recordType->fieldValues()->sync($fieldValues);
}
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
}
/**
* @inheritdoc
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$params = [];
$missingStage = null;
if ($types === null) {
$types = [Stage::TYPE_LEAD, Stage::TYPE_OPPORTUNITY];
}
foreach ($types as $type) {
if ($type === Stage::TYPE_LEAD) {
$query = '
SELECT
Id, ApiName, MasterLabel, SortOrder
FROM
LeadStatus';
} else {
$query = '
SELECT
Id, ApiName, MasterLabel, IsActive, SortOrder, DefaultProbability
FROM
OpportunityStage';
}
if ($missingStageName) {
$escapedStageName = ValueNormalizer::replaceQueryWithStringLiterals($missingStageName);
$query .= ' WHERE ApiName = :stageName';
$params = [
'stageName' => $escapedStageName,
];
}
try {
$sfStages = $this->queryHandler->query($query, $params);
} catch (NoResultsException $exception) {
$sfStages = [];
}
$missingStage = null;
// Upsert all stages for the team.
foreach ($sfStages as $sfStage) {
$selectable = true;
if (array_key_exists('IsActive', $sfStage)) {
$selectable = $sfStage['IsActive'];
}
$this->restoreAnyTrashedEntity($this->config->stages(), $sfStage['Id']);
$stage = $this->config->stages()->updateOrCreate([
'crm_provider_id' => $sfStage['Id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($sfStage['ApiName'], 0, 50),
'label' => mb_strimwidth($sfStage['MasterLabel'], 0, 191),
'type' => $type,
'sequence' => $sfStage['SortOrder'] ?? 0,
'is_selectable' => $selectable,
'probability' => $sfStage['DefaultProbability'] ?? null,
]);
if ($missingStageName && $missingStageName === $sfStage['ApiName']) {
$missingStage = $stage;
}
}
if ($missingStageName && $missingStage === null) {
// If they requested a stage that still doesn't exist, it must be inactive so lazy create it.
$missingStage = $this->config->stages()->create([
'crm_provider_id' => Uuid::uuid4(),
'team_id' => $this->team->id,
'name' => mb_strimwidth($missingStageName, 0, 50),
'label' => mb_strimwidth($missingStageName, 0, 191),
'type' => $type,
'sequence' => 0,
'is_selectable' => 0,
]);
}
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int
{
$syncCount = 0;
$fields = $this->getAllFieldsAsArray('lead');
if (\in_array('Id', $fields, true) === false) {
return $syncCount;
}
$query = '
SELECT ' . rtrim(implode(',', $fields), ',') . '
FROM Lead
WHERE LastModifiedDate > :since
ORDER BY LastModifiedDate ASC';
try {
$sfLeads = $this->queryHandler->query($query, [
'since' => $since->format('Y-m-d\TH:i:s\Z'),
]);
foreach ($sfLeads as $sfLead) {
// Only sync if previously imported.
if ($this->hasLead($sfLead['Id'])) {
$this->importLead($sfLead);
$syncCount++;
}
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
$this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::LEAD);
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncLead(string $crmId): ?Lead
{
$fields = $this->getAllFieldsAsArray('lead');
$sfLead = $this->getRecord('Lead', $crmId, $fields);
return $this->importLead($sfLead);
}
private function importLead($crmData): ?Lead
{
/** @var ?Stage $stage */
$stage = null;
if (isset($crmData['Status'])) {
// Get the current stage.
$stage = $this->config
->stages()
->where('name', $crmData['Status'])
->where('type', Stage::TYPE_LEAD)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_LEAD], $crmData['Status']);
}
}
// If we have no way of importing this, just return null :(
if ($stage === null) {
return null;
}
$countryCode = $crmData['CountryCode'] ?? null;
// Salesforce allows custom "countries" to be created. Disregard these.
if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {
$countryCode = null;
}
// If we have no country code, try to parse it from the country name.
if ($countryCode === null && empty($crmData['Country']) !== false) {
$countryCode = $this->convertCountryNameToCode($crmData['Country']);
}
// Trim to our width and attempt to parse it.
$number = mb_strimwidth($crmData['Phone'] ?? '', 0, 25);
$parsedNumber = parsePhoneNumber($countryCode, $number);
$mobilePhone = null;
if (empty($crmData['MobilePhone']) === false) {
// Trim to our width and attempt to parse it.
$number = mb_strimwidth($crmData['MobilePhone'], 0, 25);
$mobilePhone = phone_e164($countryCode, $number);
}
$convertedDate = null;
$convertedAccount = null;
$convertedOpportunity = null;
$convertedContact = null;
if ($crmData['IsConverted'] == 'true') {
$convertedDate = $crmData['ConvertedDate'];
if (empty($crmData['ConvertedAccountId']) === false) {
$convertedAccount = $this->config
->accounts()
->where('crm_provider_id', $crmData['ConvertedAccountId'])
->first();
if ($convertedAccount === null) {
try {
$convertedAccount = $this->syncAccount($crmData['ConvertedAccountId']);
} catch (HttpNotFoundException $exception) {
// Probably the user has no permissions to access the converted data.
}
}
}
if (empty($crmData['ConvertedOpportunityId']) === false) {
$convertedOpportunity = $this->config
->opportunities()
->where('crm_provider_id', $crmData['ConvertedOpportunityId'])
->first();
if ($convertedOpportunity === null) {
try {
$convertedOpportunity = $this->syncOpportunity($crmData['ConvertedOpportunityId']);
} catch (HttpNotFoundException $exception) {
// Probably the user has no permissions to access the converted data.
}
}
}
if (empty($crmData['ConvertedContactId']) === false) {
$convertedContact = $this->team
->crm
->contacts()
->where('crm_provider_id', $crmData['ConvertedContactId'])
->first();
if ($convertedContact === null) {
try {
$convertedContact = $this->syncContact($crmData['ConvertedContactId']);
} catch (HttpNotFoundException $exception) {
// Probably the user has no permissions to access the converted data.
}
}
}
}
if (empty($crmData['Company'])) {
$company = 'Unknown';
} else {
$company = mb_strimwidth($crmData['Company'], 0, 191);
}
$domain = null;
if (empty($crmData['Website']) === false) {
$domain = mb_strimwidth($crmData['Website'], 0, 191);
$domain = StringUtil::resolveDomain($domain);
}
$createdDate = null;
if (empty($crmData['CreatedDate']) === false) {
$createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');
}
$profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);
$data = [
'team_id' => $this->team->id,
'user_id' => $profile?->user_id,
'owner_id' => $crmData['OwnerId'] ?? '',
'company' => $company,
'domain' => $domain,
'name' => $crmData['Name'] ? mb_strimwidth($crmData['Name'], 0, 191) : '',
'title' => $crmData['Title'] ? mb_strimwidth($crmData['Title'], 0, 128) : null,
'email' => $crmData['Email'] ? mb_strimwidth($crmData['Email'], 0, 80) : null,
'phone' => $parsedNumber['phone'],
'ext' => $parsedNumber['ext'] ?? null,
'mobile_phone' => $mobilePhone,
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: $crmData['Id'],
modelType: Lead::class,
fileName: $crmData['Id'],
avatarText: $crmData['Name']
),
'stage_id' => $stage->id,
'record_type_id' => null,
'converted_at' => $convertedDate,
'converted_account_id' => $convertedAccount->id ?? null,
'converted_opportunity_id' => $convertedOpportunity->id ?? null,
'converted_contact_id' => $convertedContact->id ?? null,
'country_code' => $countryCode,
'remotely_created_at' => $createdDate,
];
$this->restoreAnyTrashedEntity($this->config->leads(), $crmData['Id']);
/** @var Lead $lead */
$lead = $this->config->leads()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);
if ($lead->wasChanged('converted_at') && $lead->getConvertedAt() !== null) {
$this->eventDispatcher->dispatch(new LeadConverted($lead));
}
$this->handleObjectDeletion($lead, $crmData);
return $lead;
}
/**
* @inheritdoc
*/
public function syncAccounts(Carbon $since, ?Carbon $to = null): int
{
$syncCount = 0;
$fields = $this->getAllFieldsAsArray('account');
if (\in_array('Id', $fields, true) === false) {
return $syncCount;
}
$query = '
SELECT ' . rtrim(implode(',', $fields), ',') . '
FROM Account
WHERE LastModifiedDate > :since
ORDER BY LastModifiedDate ASC';
try {
$sfAccounts = $this->queryHandler->query($query, [
'since' => $since->format('Y-m-d\TH:i:s\Z'),
]);
foreach ($sfAccounts as $sfAccount) {
// Only sync if previously imported.
if ($this->hasAccount($sfAccount['Id'])) {
$this->importAccount($sfAccount);
$syncCount++;
}
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
$this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::ACCOUNT);
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncAccount(string $crmId): ?Account
{
$fields = $this->getAllFieldsAsArray('account');
if (! in_array('Id', $fields, true)) {
$this->logger->info('[Salesforce] Sync account cancelled. Fields are not available.', [
'crmId' => $crmId,
'userId' => $this->profile->getUserId(),
]);
return null;
}
$sfAccount = $this->getRecord('Account', $crmId, $fields);
return $this->importAccount($sfAccount);
}
private function importAccount($crmData): Account
{
$countryCode = $crmData['BillingCountryCode'] ?? $crmData['ShippingCountryCode'] ?? null;
// Salesforce allows custom "countries" to be created. Disregard these.
if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {
$countryCode = null;
}
// If we have no country code, try to parse it from the country names.
if ($countryCode === null && empty($crmData['BillingCountry']) === false) {
$countryCode = $this->convertCountryNameToCode($crmData['BillingCountry']);
}
if ($countryCode === null && empty($crmData['ShippingCountry']) === false) {
$countryCode = $this->convertCountryNameToCode($crmData['ShippingCountry']);
}
if (empty($crmData['Phone']) === false) {
// Trim to our width and attempt to parse it.
$number = mb_strimwidth($crmData['Phone'], 0, 25);
$parsedNumber = parsePhoneNumber($countryCode, $number);
} else {
$parsedNumber = [];
}
$industry = null;
if (empty($crmData['Industry']) === false) {
$industry = mb_strimwidth($crmData['Industry'], 0, 40);
}
$domain = null;
if (empty($crmData['Website']) === false) {
$domain = mb_strimwidth($crmData['Website'], 0, 191);
$domain = StringUtil::resolveDomain($domain);
}
$createdDate = null;
if (empty($crmData['CreatedDate']) === false) {
$createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');
}
$profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);
$data = [
'team_id' => $this->team->id,
'user_id' => $profile?->user_id,
'owner_id' => $crmData['OwnerId'],
'name' => mb_strimwidth($crmData['Name'], 0, 191),
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: $crmData['Id'],
modelType: Account::class,
fileName: $crmData['Id'],
avatarText: $crmData['Name']
),
'industry' => $industry,
'domain' => $domain,
'phone' => $parsedNumber['phone'] ?? null,
'ext' => $parsedNumber['ext'] ?? null,
'country_code' => $countryCode,
'remotely_created_at' => $createdDate,
];
$this->restoreAnyTrashedEntity($this->config->accounts(), $crmData['Id']);
/** @var Account $account */
$account = $this->config->accounts()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);
$this->handleObjectDeletion($account, $crmData);
return $account;
}
/**
* @inheritdoc
*/
public function syncOpportunities(array $parameters, ?string $strategy = null): int
{
$strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);
$syncCount = 0;
$logParams = $parameters;
$parameters['profile'] = $this->profile;
$logParams['user'] = $this->profile->getUserId();
if (count($strategies) > 1) {
$this->logger->warning('[' . $this->getDisplayName() . '] Multiple sync strategies used', [
'teamId' => $this->team->getUuid(),
'params' => $logParams,
'strategies_count' => count($strategies),
]);
}
foreach ($strategies as $syncStrategy) {
$name = $syncStrategy->getStrategyName();
try {
$sfOpportunities = $syncStrategy->fetchOpportunities($parameters);
$totalRecords = $sfOpportunities->count();
foreach ($sfOpportunities as $sfOpportunity) {
$this->importOpportunity($sfOpportunity);
$syncCount++;
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
$this->logger->warning('[' . $this->getDisplayName() . '] No opportunities found', [
'teamId' => $this->team->getUuid(),
'name' => $name,
'params' => $logParams,
'reason' => $noResultsException->getMessage(),
]);
} catch (CrmException $crmException) {
// Nothing to sync.
$this->logger->warning('[' . $this->getDisplayName() . '] Opportunity sync failed', [
'teamId' => $this->team->getUuid(),
'name' => $name,
'params' => $logParams,
'reason' => $crmException->getMessage(),
]);
}
}
$this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::OPPORTUNITY, ['params' => $logParams]);
// debug to see how if count of opportunities reaches 1000
if ($syncCount >= 1000) {
$this->logger->info(
'[' . $this->getDisplayName() . '] Sync Opportunities - count warning',
[
'team_id' => $this->team->getId(),
'params' => $logParams,
'count' => $syncCount,
'strategies_count' => count($strategies),
'total_records' => $totalRecords ?? null,
]
);
}
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncOpportunity(string $crmId): ?Opportunity
{
$strategy = $this->opportunitySyncStrategyResolver->resolve(
$this->config,
OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY
);
$parameters = [
'profile' => $this->profile,
'crm_id' => $crmId,
];
try {
$sfOpportunity = $strategy->fetchOpportunities($parameters);
} catch (HttpNotFoundException $e) {
$this->logger->info('[' . $this->getDisplayName() . '] Opportunity not found', [
'teamId' => $this->team->id_string,
'crmId' => $crmId,
]);
return null;
} catch (CrmException $crmException) {
$this->logger->info('[' . $this->getDisplayName() . '] Opportunity sync failed', [
'teamId' => $this->team->id_string,
'crmId' => $crmId,
'exception' => $crmException->getMessage(),
]);
return null;
}
if ($sfOpportunity instanceof ArrayIterator) {
return $this->importOpportunity($sfOpportunity->getItems());
}
return $this->importOpportunity($sfOpportunity);
}
/**
* @throws HttpNotFoundException
*/
private function importOpportunity($crmData): ?Opportunity
{
$profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);
$account = null;
if (empty($crmData['AccountId']) === false) {
/** @var ?Account $account */
$account = $this->config->accounts()
->where('crm_provider_id', (string) $crmData['AccountId'])
->first();
if ($account === null) {
$account = $this->syncAccount($crmData['AccountId']);
}
}
$userId = $profile?->getUserId() ?? $account?->getUserId();
if ($us...
|
[{"role":"AXStaticText","text& [{"role":"AXStaticText","text":"Diff","depth":1,"bounds":{"left":0.25531915,"top":0.39185953,"width":0.013962766,"height":0.013567438},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Changelist:","depth":1,"bounds":{"left":0.38231382,"top":0.15802075,"width":0.022938829,"height":0.027134877},"on_screen":true,"role_description":"text"},{"role":"AXPopUpButton","text":"Changelist:","depth":1,"bounds":{"left":0.40658244,"top":0.15802075,"width":0.042220745,"height":0.027134877},"on_screen":true,"role_description":"pop up button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Changes","depth":6,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Show Diff","depth":2,"bounds":{"left":0.25664893,"top":0.16201118,"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":"Rollback...","depth":2,"bounds":{"left":0.26529256,"top":0.16201118,"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":"Refresh","depth":2,"bounds":{"left":0.27393618,"top":0.16201118,"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":"Group By","depth":2,"bounds":{"left":0.28490692,"top":0.16201118,"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":"Expand All","depth":2,"bounds":{"left":0.3617021,"top":0.16201118,"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":"Collapse All","depth":2,"bounds":{"left":0.37034574,"top":0.16201118,"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":"AXStaticText","text":"1 modified","depth":1,"bounds":{"left":0.25531915,"top":0.254589,"width":0.1931516,"height":0.016759777},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"JY-20","depth":2,"bounds":{"left":0.2556516,"top":0.3008779,"width":0.19281915,"height":0.087789305},"on_screen":true,"value":"JY-20","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Commit Message","depth":1,"bounds":{"left":0.25531915,"top":0.28252193,"width":0.03557181,"height":0.013567438},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Commit Message History","depth":2,"bounds":{"left":0.4401596,"top":0.27853152,"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":"AXStaticText","text":"Git","depth":2,"bounds":{"left":0.45212767,"top":0.16280925,"width":0.0056515955,"height":0.013567438},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Author:","depth":2,"bounds":{"left":0.46010637,"top":0.18834797,"width":0.014960106,"height":0.013567438},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"Author:","depth":3,"bounds":{"left":0.4787234,"top":0.18834797,"width":0.09840426,"height":0.013567438},"on_screen":true,"role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Amend commit","depth":2,"bounds":{"left":0.4587766,"top":0.21069433,"width":0.039893616,"height":0.019952115},"on_screen":true,"help_text":"Merge this commit with the previous one","role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Sign-off commit","depth":2,"bounds":{"left":0.4587766,"top":0.23383878,"width":0.042220745,"height":0.019952115},"on_screen":true,"help_text":"<html>Adds the following line at the end of the commit message:<br/>Signed-off by: Lukas Kovalik <kovaliklukas@gmail.com></html>","role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Create extra commit with file movements","depth":2,"on_screen":false,"role_description":"checkbox","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Commit Checks","depth":2,"bounds":{"left":0.45212767,"top":0.27613726,"width":0.032247342,"height":0.013567438},"on_screen":true,"role_description":"text"},{"role":"AXCheckBox","text":"Update copyright","depth":2,"bounds":{"left":0.4587766,"top":0.29608938,"width":0.04488032,"height":0.019952115},"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Reformat code","depth":2,"bounds":{"left":0.4587766,"top":0.31923383,"width":0.03956117,"height":0.019952115},"on_screen":true,"help_text":"","role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Rearrange code","depth":2,"bounds":{"left":0.4587766,"top":0.3423783,"width":0.041888297,"height":0.019952115},"on_screen":true,"help_text":"","role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Optimize imports","depth":2,"bounds":{"left":0.4587766,"top":0.36552274,"width":0.044215426,"height":0.019952115},"on_screen":true,"help_text":"","role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Cleanup","depth":2,"bounds":{"left":0.4587766,"top":0.3886672,"width":0.026263298,"height":0.019952115},"on_screen":true,"help_text":"","role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"Choose profile","depth":2,"bounds":{"left":0.48902926,"top":0.39185953,"width":0.029920213,"height":0.013567438},"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Check malicious dependencies","depth":2,"bounds":{"left":0.4587766,"top":0.41181165,"width":0.07247341,"height":0.019952115},"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Run external formatter","depth":2,"bounds":{"left":0.4587766,"top":0.4349561,"width":0.05518617,"height":0.019952115},"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"Configure","depth":2,"bounds":{"left":0.51795214,"top":0.43814844,"width":0.019946808,"height":0.013567438},"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Perform SonarQube for IDE analysis","depth":2,"bounds":{"left":0.4587766,"top":0.45810056,"width":0.1200133,"height":0.019952115},"on_screen":true,"help_text":"","role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Find unused view files","depth":2,"bounds":{"left":0.4587766,"top":0.481245,"width":0.054521278,"height":0.019952115},"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Run Git hooks","depth":2,"on_screen":false,"help_text":"If unchecked, Git hooks will be skipped with the '--no-verify' option for the upcoming commit","role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Advanced Commit Checks","depth":2,"bounds":{"left":0.45212767,"top":0.5235435,"width":0.05418883,"height":0.013567438},"on_screen":true,"role_description":"text"},{"role":"AXCheckBox","text":"Analyze code","depth":2,"bounds":{"left":0.4587766,"top":0.5434956,"width":0.036901597,"height":0.019952115},"on_screen":true,"help_text":"","role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"Choose profile","depth":2,"bounds":{"left":0.49966756,"top":0.54668796,"width":0.029920213,"height":0.013567438},"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Check TODO","depth":2,"bounds":{"left":0.4587766,"top":0.5666401,"width":0.035904255,"height":0.019952115},"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"Configure","depth":2,"bounds":{"left":0.49867022,"top":0.5698324,"width":0.019946808,"height":0.013567438},"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"After Commit","depth":2,"bounds":{"left":0.45212767,"top":0.6089386,"width":0.027260639,"height":0.013567438},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Upload files to:","depth":2,"bounds":{"left":0.46010637,"top":0.6360734,"width":0.030585106,"height":0.013567438},"on_screen":true,"role_description":"text"},{"role":"AXPopUpButton","text":"Upload files to:","depth":2,"bounds":{"left":0.49168882,"top":0.62968874,"width":0.07679521,"height":0.027134877},"on_screen":true,"role_description":"pop up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"<None>","depth":7,"on_screen":false,"role_description":"text"},{"role":"AXCheckBox","text":"Always use selected server or group of servers","depth":2,"bounds":{"left":0.46675533,"top":0.66081405,"width":0.10538564,"height":0.019952115},"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Previous Difference","depth":2,"bounds":{"left":0.25664893,"top":0.41340783,"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 Difference","depth":2,"bounds":{"left":0.26529256,"top":0.41340783,"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":"Jump to Source","depth":2,"bounds":{"left":0.27393618,"top":0.41340783,"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":"Compare Previous File","depth":2,"bounds":{"left":0.28490692,"top":0.41340783,"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":"Compare Next File","depth":2,"bounds":{"left":0.29355052,"top":0.41340783,"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":"Go to Changed File…","depth":2,"bounds":{"left":0.30219415,"top":0.41340783,"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":"Side-by-side viewer","depth":2,"bounds":{"left":0.3118351,"top":0.41340783,"width":0.04720745,"height":0.01915403},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Do not ignore","depth":2,"bounds":{"left":0.36336437,"top":0.41340783,"width":0.03557181,"height":0.01915403},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Highlight words","depth":2,"bounds":{"left":0.40093085,"top":0.41340783,"width":0.03956117,"height":0.01915403},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse Unchanged Fragments","depth":2,"bounds":{"left":0.44148937,"top":0.41340783,"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":"Synchronize Scrolling","depth":2,"bounds":{"left":0.45013297,"top":0.41340783,"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":"Disable Editing","depth":2,"bounds":{"left":0.4587766,"top":0.41340783,"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":"Settings","depth":2,"bounds":{"left":0.46742022,"top":0.41340783,"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":"Help","depth":2,"bounds":{"left":0.47839096,"top":0.41340783,"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":"AXStaticText","text":"4 differences","depth":1,"bounds":{"left":0.5511968,"top":0.41181165,"width":0.027260639,"height":0.022346368},"on_screen":true,"role_description":"text"},{"role":"AXTextField","text":"1aee7aad","depth":1,"bounds":{"left":0.26196808,"top":0.43415803,"width":0.022606382,"height":0.013567438},"on_screen":true,"value":"1aee7aad","help_text":"text/plain","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Salesforce;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse Illuminate\\Database\\Eloquent\\Relations\\HasMany;\nuse Illuminate\\Events\\Dispatcher;\nuse Illuminate\\Support\\Facades\\Cache;\nuse Illuminate\\Support\\Facades\\Log;\nuse Illuminate\\Support\\Str;\nuse Jiminny\\Component\\Country\\CountriesMap;\nuse Jiminny\\Contracts\\Acl\\PermissionEnum;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\FetchRelatedActivityInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\ImportsBusinessProcessesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\SalesforceBatchSyncInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\SalesforceInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteNoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SearchTaskInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SendSummaryToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SupportsObjectTypeParseInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmProfileRecordTypesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Enums\\CrmObject;\nuse Jiminny\\Events\\Activities\\Crm\\LeadConverted;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpBadRequestException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Exceptions\\NoResultsException;\nuse Jiminny\\Exceptions\\ServiceUnavailableException;\nuse Jiminny\\Jobs\\Crm\\NoteObject;\nuse Jiminny\\Jobs\\Crm\\MatchActivitiesToNewOpportunity;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Calendar\\CalendarEvent;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Contracts\\ActivityContract;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Configuration;\nuse Jiminny\\Models\\Crm\\ContactRole;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Crm\\RecordType;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\TeamSettings;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\ContactRoleRepository;\nuse Jiminny\\Repositories\\Crm\\FieldRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Repositories\\Crm\\RecordTypeFieldValuesRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\Helpers\\ArrayIterator;\nuse Jiminny\\Services\\Crm\\MatchDomainByEmailInterface;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Services\\Crm\\ResolveCompanyNameByEmailTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\Fields\\FieldHelper;\nuse Jiminny\\Services\\Crm\\Salesforce\\Fields\\FieldTypeConverter;\nuse Jiminny\\Services\\Crm\\Salesforce\\Fields\\ValueNormalizer;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\FollowupActivityTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\LogActivityTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\RecordManipulationsTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\SyncFieldsTrait;\nuse Jiminny\\Utils\\CurrencyFormatter;\nuse Jiminny\\Utils\\StringUtil;\nuse Ramsey\\Uuid\\Uuid;\nuse Sentry\\Laravel\\Facade as Sentry;\n\nclass Service extends BaseService implements\n SalesforceInterface,\n SalesforceBatchSyncInterface,\n SyncCrmEntitiesInterface,\n SyncCrmProfileRecordTypesInterface,\n ImportsBusinessProcessesInterface,\n RemoteEntityManipulationInterface,\n FetchRelatedActivityInterface,\n SendSummaryToCrmInterface,\n MatchDomainByEmailInterface,\n SearchTaskInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n SupportsObjectTypeParseInterface,\n RemoteNoteEntityManipulationInterface,\n VerifyTaskExistsInterface\n{\n use ResolveCompanyNameByEmailTrait;\n use SyncFieldsTrait;\n use DeleteObjectsTrait;\n use RecordManipulationsTrait;\n use ServiceTraits\\BatchSyncTrait;\n use FollowupActivityTrait;\n use LogActivityTrait;\n\n /**\n * Note Body Limit for the Old Note-Taking Tool\n *\n * @var int\n */\n private const int CLASSIC_NOTE_MAX_LENGTH = 32000;\n\n /**\n * Note Content Limit for the New Notes\n *\n * @var int\n */\n private const int ENHANCED_NOTE_MAX_LENGTH = 50000000;\n\n private const string INSTALLED_PACKAGE_ID = '033Tw0000007bKbIAI';\n\n private const int CACHE_TTL = 600;\n\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day - 86400\n\n /**\n * @var Client\n */\n protected $client;\n\n protected PayloadBuilder $payloadBuilder;\n protected QueryHandler $queryHandler;\n\n private OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n\n public function __construct(\n Client $client,\n PayloadBuilder $payloadBuilder,\n protected Dispatcher $eventDispatcher,\n private readonly CountriesMap $countriesMap,\n private readonly ProspectPhotoPathService $prospectPhotoPathService,\n ) {\n parent::__construct();\n\n $this->client = $client;\n $this->payloadBuilder = $payloadBuilder;\n $this->queryHandler = app(QueryHandler::class, [\n 'client' => $this->client,\n 'logger' => $this->logger,\n ]);\n $this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n }\n\n public function getDisplayName(): string\n {\n return 'Salesforce';\n }\n\n public function getJobDelay(): int\n {\n return 1;\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n return $user->getSocialAccount(SocialAccount::PROVIDER_SALESFORCE);\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($activity, $crmProviderId) {\n $playbook = $this->getPlaybookFromActivity($activity);\n\n if ($playbook === null) {\n $this->logger->warning('[Salesforce] Cannot verify task - no playbook found', [\n 'activity' => $activity->getId(),\n 'crm_provider_id' => $crmProviderId,\n ]);\n\n return false;\n }\n\n $objectType = $playbook->getActivityType() === Playbook::ACTIVITY_TYPE_EVENT ? 'Event' : 'Task';\n\n try {\n $record = $this->getRecord($objectType, $crmProviderId, ['Id', 'IsDeleted']);\n\n return ! empty($record) && ($record['IsDeleted'] ?? false) === false;\n } catch (HttpNotFoundException|HttpBadRequestException) {\n $this->logger->info('[Salesforce] Activity record not found during verification', [\n 'activity' => $activity->getId(),\n 'object_type' => $objectType,\n 'crm_provider_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n });\n }\n\n public function query(string $queryToRun, array $parameters = []): QueryIterator\n {\n // Due to poorly designed external calls, this method cannot be entirely removed\n return $this->queryHandler->query($queryToRun, $parameters);\n }\n\n /*=========== Organization Information ===============*/\n\n /**\n * Get a list of all the API Versions for the instance.\n *\n * @throws CrmException\n *\n * @return mixed\n *\n */\n public function getApiVersions()\n {\n $url = $this->config->crm_base_url . '/services/data';\n\n $response = $this->client->get($url);\n\n return json_decode($response->getBody(), true);\n }\n\n /**\n * Gets the valid recordTypes for a given Salesforce Object via the describe API.\n */\n private function getRecordTypes(string $crmObject): array\n {\n $url = $this->client->getObjectsUrl() . $crmObject . '/describe';\n\n $response = $this->client->get($url);\n $jsonResponse = json_decode($response->getBody(), true);\n\n $fields = [];\n foreach ($jsonResponse['recordTypeInfos'] as $row) {\n $fields[] = ['recordTypeId' => $row['recordTypeId'], 'default' => $row['defaultRecordTypeMapping']];\n }\n\n return $fields;\n }\n\n /**\n * Convert raw field data into a format compatible with CRM APIs.\n */\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return ValueNormalizer::normalize($fieldType, $fieldValue, $internal);\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n $defaultFields = ($activityType === Playbook::ACTIVITY_TYPE_TASK)\n ? FieldDefinitions::defaultTaskFields()\n : FieldDefinitions::defaultEventFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n // Setup the activity field as the default Type.\n /** @var Field $activityField */\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'Type',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK, Playbook::ACTIVITY_TYPE_EVENT];\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n\n $fieldFilter = ($activityType === Playbook::ACTIVITY_TYPE_TASK)\n ? FieldDefinitions::taskFollowupFieldsFilter()\n : FieldDefinitions::eventFollowupFieldsFilter();\n\n foreach ($fieldFilter as $eachFilter) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $eachFilter);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n /**\n * This one is now called only when ImportActivityTypes is triggered or SyncFieldMetadata executed manually\n * Regular sync now uses SharedSyncFieldsTrait -> syncSingleObjectType\n * Needs to be replaced later on\n */\n public function syncField(Field $field): void\n {\n try {\n if (FieldHelper::isCustomField($field)) {\n $query = '\n SELECT\n Id, Metadata, TableEnumOrId\n FROM\n CustomField\n WHERE\n DeveloperName = :fieldName\n AND\n TableEnumOrId = :fieldType\n AND\n NamespacePrefix = :namespacePrefix';\n\n // We need to constrain the field lookup to the object, in case it's used in multiple places.\n $objectType = \\in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true)\n ? 'activity'\n : $field->object_type;\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'fieldName' => substr($field->crm_provider_id, 0, -\\strlen('__c')),\n 'fieldType' => ucfirst($objectType),\n\n // This is used to ensure we only consider the field within the org, not installed packages.\n 'namespacePrefix' => 'null',\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n // Sync field metadata.\n $metadata = $sfField['Metadata'];\n\n $field->description = mb_strimwidth($metadata['description'] ?? '', 0, 191);\n $field->label = mb_strimwidth($metadata['label'] ?? '', 0, Field::LABEL_MAX_LENGTH);\n $field->type = $this->convertFieldType($metadata['type'], $field->getEntityName());\n $field->is_mandatory = ($metadata['required'] === true);\n $field->length = $metadata['length'];\n $field->default_value = mb_strimwidth(trim($metadata['defaultValue'] ?? '', '\"'), 0, 191);\n $field->save();\n } else {\n $query = '\n SELECT\n Id, DataType, DeveloperName, Label, Length, Description\n FROM\n FieldDefinition\n WHERE\n DurableId = :entityName';\n\n $entityName = $field->getEntityName();\n $sfFields = $this->queryHandler->metadata($query, [\n 'entityName' => $entityName,\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n $convertedType = $this->convertFieldType($sfField['DataType'], $entityName);\n $label = mb_strimwidth($sfField['Label'], 0, Field::LABEL_MAX_LENGTH);\n\n if ($field->isBusinessType()) {\n $label = 'Opportunity Type';\n }\n\n $field->description = mb_strimwidth($sfField['Description'], 0, Field::DESCRIPTION_MAX_LENGTH);\n $field->label = $label;\n $field->type = $convertedType;\n $field->length = $sfField['Length'];\n $field->save();\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n private function convertFieldType(string $from, ?string $entityName = null): string\n {\n $converter = new FieldTypeConverter();\n\n return $converter->convert($from, $entityName);\n }\n\n /**\n * @inheritdoc\n */\n public function importPicklistValues(Field $field): array\n {\n $values = [];\n $fieldValues = [];\n\n try {\n if (FieldHelper::isCustomField($field)) {\n $query = '\n SELECT\n Id, Metadata, TableEnumOrId\n FROM\n CustomField\n WHERE\n DeveloperName = :fieldName\n AND\n TableEnumOrId = :fieldType\n AND\n NamespacePrefix = :namespacePrefix';\n\n // We need to constrain the field lookup to the object, in case it's used in multiple places.\n $objectType = \\in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true) ?\n 'activity' : $field->object_type;\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'fieldName' => substr($field->crm_provider_id, 0, -\\strlen('__c')),\n 'fieldType' => ucfirst($objectType),\n // This is used to ensure we only consider the field within the org, not installed packages.\n 'namespacePrefix' => 'null',\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n $valueSet = $sfField['Metadata']['valueSet'];\n\n if ($valueSet['valueSetName'] === null) {\n // Local picklist values can be obtained easily.\n $picklistValues = $valueSet['valueSetDefinition']['value'];\n } else {\n // But for some fields, we just get the Global Value Picklist pointer so need to do more work.\n $picklistValues = $this->importGlobalValuePicklistValues($valueSet['valueSetName']);\n }\n\n // Import all active values.\n foreach ($picklistValues as $i => $sfFieldValue) {\n // Setup default value.\n if ($sfFieldValue['default']) {\n $field->update(['default_value' => $sfFieldValue['valueName']]);\n }\n\n // This comes through as null if active (lol).\n if ($sfFieldValue['isActive'] !== false) {\n $values[] = [\n 'value' => $sfFieldValue['valueName'],\n 'label' => $sfFieldValue['valueName'],\n 'sequence' => $i,\n 'is_default' => $sfFieldValue['default'],\n ];\n }\n }\n } else {\n $objectFields = $this->getObjectFields($field->object_type);\n $fieldId = $field->crm_provider_id;\n\n // Only work with our field of interest.\n $objectField = array_filter($objectFields, function ($item) use ($fieldId) {\n return $item['name'] === $fieldId;\n });\n\n $objectField = array_shift($objectField);\n if (empty($objectField['picklistValues']) === false) {\n foreach ($objectField['picklistValues'] as $i => $sfFieldValue) {\n // Skip inactive values.\n if ($sfFieldValue['active'] === false) {\n continue;\n }\n\n // Setup default value.\n if ($sfFieldValue['defaultValue']) {\n $field->update(['default_value' => $sfFieldValue['value']]);\n }\n\n $values[] = [\n 'value' => $sfFieldValue['value'],\n 'label' => $sfFieldValue['label'],\n 'sequence' => $i,\n 'is_default' => $sfFieldValue['defaultValue'],\n ];\n }\n }\n }\n\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n $value['value'] = substr($value['value'] ?? '', 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n if (($key = array_search($value['value'], $fieldsToPurge, true)) !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n // Get IDs of the values to be deleted\n $valuesToDelete = $field->values()->whereIn('value', $fieldsToPurge);\n $valuesToDeleteIds = $valuesToDelete->pluck('id');\n if (! $valuesToDeleteIds->isEmpty()) {\n $recordTypeFieldValuesRepository = app(RecordTypeFieldValuesRepository::class);\n $recordTypeFieldValuesRepository->deleteForCrmFieldValueIds($valuesToDeleteIds->toArray());\n\n // Now safely delete from crm_field_values\n $valuesToDelete->delete();\n }\n\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n return $fieldValues;\n }\n\n /**\n * Gets values from Global Value Picklists.\n */\n private function importGlobalValuePicklistValues(string $picklistName): array\n {\n $query = '\n SELECT\n Metadata\n FROM\n GlobalValueSet\n WHERE\n DeveloperName = :picklistName\n LIMIT 1';\n\n try {\n $sfValues = $this->queryHandler->metadata($query, [\n 'picklistName' => $picklistName,\n ]);\n\n // There is always 1 result at this point.\n $sfValue = $sfValues->current();\n\n return $sfValue['Metadata']['customValue'];\n } catch (NoResultsException $noResultsException) {\n // Nothing returned.\n\n return [];\n }\n }\n\n /**\n * @inheritdoc\n */\n public function syncProfileRecordTypes(): void\n {\n $objectTypes = [\n 'lead',\n 'account',\n 'contact',\n 'opportunity',\n 'task',\n 'event',\n ];\n\n foreach ($objectTypes as $objectType) {\n try {\n $crmRecordTypes = $this->getRecordTypes(ucfirst($objectType));\n\n foreach ($crmRecordTypes as $crmRecordType) {\n // If the record type is default and not the Master type, set this.\n if ($crmRecordType['default'] && $crmRecordType['recordTypeId'] !== '012000000000000AAA') {\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $crmRecordType['recordTypeId'])\n ->first();\n\n if ($recordType) {\n $this->profile->{$objectType . '_record_type_id'} = $recordType->id;\n }\n }\n }\n } catch (HttpNotFoundException $exception) {\n Log::error('No access to ' . $objectType . ' object, skipping...');\n\n // XXX: should we log this fact somewhere?\n continue;\n }\n }\n\n if ($this->profile->isDirty()) {\n $this->profile->save();\n }\n }\n\n /**\n * Gets business processes.\n */\n public function importBusinessProcesses(): void\n {\n $query = '\n SELECT\n Id, IsActive, Name, TableEnumOrId\n FROM\n BusinessProcess\n WHERE\n TableEnumOrId IN (\\'Lead\\',\\'Opportunity\\')';\n\n try {\n $sfProcesses = $this->queryHandler->query($query);\n\n // Upsert all processes for the team.\n foreach ($sfProcesses as $sfProcess) {\n /** @var BusinessProcess $businessProcess */\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $sfProcess['Id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => $sfProcess['Name'],\n 'type' => $sfProcess['TableEnumOrId'] === 'Lead' ? 'lead' : 'opportunity',\n 'is_selectable' => $sfProcess['IsActive'],\n ]);\n\n $this->importBusinessProcessStages($businessProcess);\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n /**\n * Gets business process stages.\n */\n private function importBusinessProcessStages(BusinessProcess $businessProcess): void\n {\n $query = '\n SELECT\n Metadata\n FROM\n BusinessProcess\n WHERE\n Id = :processId';\n\n try {\n $stages = [];\n $sfProcessStages = $this->queryHandler->metadata($query, [\n 'processId' => $businessProcess->crm_provider_id,\n ]);\n\n // There is always 1 result at this point.\n $sfProcessStage = $sfProcessStages->current();\n\n // Upsert all processes for the team.\n foreach ($sfProcessStage['Metadata']['values'] as $sfProcessStage) {\n $sanitizedName = urldecode($sfProcessStage['valueName']); // Must decode: \"%2C\" becomes \",\" etc.\n\n $stage = $businessProcess->crm->stages()\n // This MUST match on label because this API doesn't use API Name.\n ->where('label', $sanitizedName)\n ->where('type', $businessProcess->type)\n ->where('is_selectable', 1)\n ->first();\n\n if ($stage) {\n $stages[] = $stage->id;\n }\n }\n\n $businessProcess->stages()->sync($stages);\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n /**\n * Gets record types.\n */\n public function importRecordTypes(): void\n {\n $query = '\n SELECT\n Id, IsActive, Name, BusinessProcessId, SobjectType\n FROM\n RecordType';\n\n try {\n $sfRecordTypes = $this->queryHandler->query($query);\n\n // Upsert all record types for the process.\n foreach ($sfRecordTypes as $sfRecordType) {\n $businessProcess = null;\n if ($sfRecordType['BusinessProcessId']) {\n $businessProcess = $this->config->businessProcesses()\n ->where('crm_provider_id', $sfRecordType['BusinessProcessId'])\n ->first();\n }\n\n /** @var RecordType $recordType */\n $recordType = $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $sfRecordType['Id'],\n ], [\n 'team_id' => $this->team->id,\n 'type' => mb_strtolower($sfRecordType['SobjectType']),\n 'name' => $sfRecordType['Name'],\n 'is_selectable' => $sfRecordType['IsActive'],\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n $this->importRecordTypeFieldValues($recordType);\n }\n } catch (NoResultsException $noResultsException) {\n // Do nothing.\n }\n }\n\n /**\n * Import record type - field value mappings. This only works for standard fields.\n */\n private function importRecordTypeFieldValues(RecordType $recordType): void\n {\n try {\n $query = '\n SELECT\n Metadata\n FROM\n RecordType\n WHERE\n Id = :recordTypeId';\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'recordTypeId' => $recordType->crm_provider_id,\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n // Sync field metadata.\n $picklists = $sfField['Metadata']['picklistValues'];\n\n foreach ($picklists as $picklist) {\n $field = $this->config->fields()->where([\n 'type' => Field::TYPE_PICKLIST,\n 'object_type' => $recordType->type,\n 'crm_provider_id' => $picklist['picklist'],\n ])->first();\n\n if ($field) {\n $fieldValues = [];\n\n foreach ($picklist['values'] as $value) {\n // Must decode: \"%2C\" becomes \",\" etc.\n $fieldValue = $field->values()\n ->where('value', urldecode($value['valueName']))\n ->first();\n\n if ($fieldValue) {\n $fieldValues[] = $fieldValue->id;\n }\n }\n\n $recordType->fieldValues()->sync($fieldValues);\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n /**\n * @inheritdoc\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $params = [];\n $missingStage = null;\n if ($types === null) {\n $types = [Stage::TYPE_LEAD, Stage::TYPE_OPPORTUNITY];\n }\n\n foreach ($types as $type) {\n if ($type === Stage::TYPE_LEAD) {\n $query = '\n SELECT\n Id, ApiName, MasterLabel, SortOrder\n FROM\n LeadStatus';\n } else {\n $query = '\n SELECT\n Id, ApiName, MasterLabel, IsActive, SortOrder, DefaultProbability\n FROM\n OpportunityStage';\n }\n\n if ($missingStageName) {\n $escapedStageName = ValueNormalizer::replaceQueryWithStringLiterals($missingStageName);\n\n $query .= ' WHERE ApiName = :stageName';\n\n $params = [\n 'stageName' => $escapedStageName,\n ];\n }\n\n try {\n $sfStages = $this->queryHandler->query($query, $params);\n } catch (NoResultsException $exception) {\n $sfStages = [];\n }\n\n $missingStage = null;\n\n // Upsert all stages for the team.\n foreach ($sfStages as $sfStage) {\n $selectable = true;\n if (array_key_exists('IsActive', $sfStage)) {\n $selectable = $sfStage['IsActive'];\n }\n\n $this->restoreAnyTrashedEntity($this->config->stages(), $sfStage['Id']);\n\n $stage = $this->config->stages()->updateOrCreate([\n 'crm_provider_id' => $sfStage['Id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($sfStage['ApiName'], 0, 50),\n 'label' => mb_strimwidth($sfStage['MasterLabel'], 0, 191),\n 'type' => $type,\n 'sequence' => $sfStage['SortOrder'] ?? 0,\n 'is_selectable' => $selectable,\n 'probability' => $sfStage['DefaultProbability'] ?? null,\n ]);\n\n if ($missingStageName && $missingStageName === $sfStage['ApiName']) {\n $missingStage = $stage;\n }\n }\n\n if ($missingStageName && $missingStage === null) {\n // If they requested a stage that still doesn't exist, it must be inactive so lazy create it.\n $missingStage = $this->config->stages()->create([\n 'crm_provider_id' => Uuid::uuid4(),\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($missingStageName, 0, 50),\n 'label' => mb_strimwidth($missingStageName, 0, 191),\n 'type' => $type,\n 'sequence' => 0,\n 'is_selectable' => 0,\n ]);\n }\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int\n {\n $syncCount = 0;\n $fields = $this->getAllFieldsAsArray('lead');\n if (\\in_array('Id', $fields, true) === false) {\n return $syncCount;\n }\n\n $query = '\n SELECT ' . rtrim(implode(',', $fields), ',') . '\n FROM Lead\n WHERE LastModifiedDate > :since\n ORDER BY LastModifiedDate ASC';\n\n try {\n $sfLeads = $this->queryHandler->query($query, [\n 'since' => $since->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($sfLeads as $sfLead) {\n // Only sync if previously imported.\n if ($this->hasLead($sfLead['Id'])) {\n $this->importLead($sfLead);\n $syncCount++;\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::LEAD);\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLead(string $crmId): ?Lead\n {\n $fields = $this->getAllFieldsAsArray('lead');\n\n $sfLead = $this->getRecord('Lead', $crmId, $fields);\n\n return $this->importLead($sfLead);\n }\n\n private function importLead($crmData): ?Lead\n {\n /** @var ?Stage $stage */\n $stage = null;\n if (isset($crmData['Status'])) {\n // Get the current stage.\n $stage = $this->config\n ->stages()\n ->where('name', $crmData['Status'])\n ->where('type', Stage::TYPE_LEAD)\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_LEAD], $crmData['Status']);\n }\n }\n\n // If we have no way of importing this, just return null :(\n if ($stage === null) {\n return null;\n }\n\n $countryCode = $crmData['CountryCode'] ?? null;\n\n // Salesforce allows custom \"countries\" to be created. Disregard these.\n if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {\n $countryCode = null;\n }\n\n // If we have no country code, try to parse it from the country name.\n if ($countryCode === null && empty($crmData['Country']) !== false) {\n $countryCode = $this->convertCountryNameToCode($crmData['Country']);\n }\n\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($crmData['Phone'] ?? '', 0, 25);\n $parsedNumber = parsePhoneNumber($countryCode, $number);\n\n $mobilePhone = null;\n if (empty($crmData['MobilePhone']) === false) {\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($crmData['MobilePhone'], 0, 25);\n $mobilePhone = phone_e164($countryCode, $number);\n }\n\n $convertedDate = null;\n $convertedAccount = null;\n $convertedOpportunity = null;\n $convertedContact = null;\n\n if ($crmData['IsConverted'] == 'true') {\n $convertedDate = $crmData['ConvertedDate'];\n\n if (empty($crmData['ConvertedAccountId']) === false) {\n $convertedAccount = $this->config\n ->accounts()\n ->where('crm_provider_id', $crmData['ConvertedAccountId'])\n ->first();\n\n if ($convertedAccount === null) {\n try {\n $convertedAccount = $this->syncAccount($crmData['ConvertedAccountId']);\n } catch (HttpNotFoundException $exception) {\n // Probably the user has no permissions to access the converted data.\n }\n }\n }\n\n if (empty($crmData['ConvertedOpportunityId']) === false) {\n $convertedOpportunity = $this->config\n ->opportunities()\n ->where('crm_provider_id', $crmData['ConvertedOpportunityId'])\n ->first();\n\n if ($convertedOpportunity === null) {\n try {\n $convertedOpportunity = $this->syncOpportunity($crmData['ConvertedOpportunityId']);\n } catch (HttpNotFoundException $exception) {\n // Probably the user has no permissions to access the converted data.\n }\n }\n }\n\n if (empty($crmData['ConvertedContactId']) === false) {\n $convertedContact = $this->team\n ->crm\n ->contacts()\n ->where('crm_provider_id', $crmData['ConvertedContactId'])\n ->first();\n\n if ($convertedContact === null) {\n try {\n $convertedContact = $this->syncContact($crmData['ConvertedContactId']);\n } catch (HttpNotFoundException $exception) {\n // Probably the user has no permissions to access the converted data.\n }\n }\n }\n }\n\n if (empty($crmData['Company'])) {\n $company = 'Unknown';\n } else {\n $company = mb_strimwidth($crmData['Company'], 0, 191);\n }\n\n $domain = null;\n if (empty($crmData['Website']) === false) {\n $domain = mb_strimwidth($crmData['Website'], 0, 191);\n $domain = StringUtil::resolveDomain($domain);\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $data = [\n 'team_id' => $this->team->id,\n 'user_id' => $profile?->user_id,\n 'owner_id' => $crmData['OwnerId'] ?? '',\n 'company' => $company,\n 'domain' => $domain,\n 'name' => $crmData['Name'] ? mb_strimwidth($crmData['Name'], 0, 191) : '',\n 'title' => $crmData['Title'] ? mb_strimwidth($crmData['Title'], 0, 128) : null,\n 'email' => $crmData['Email'] ? mb_strimwidth($crmData['Email'], 0, 80) : null,\n 'phone' => $parsedNumber['phone'],\n 'ext' => $parsedNumber['ext'] ?? null,\n 'mobile_phone' => $mobilePhone,\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['Id'],\n modelType: Lead::class,\n fileName: $crmData['Id'],\n avatarText: $crmData['Name']\n ),\n 'stage_id' => $stage->id,\n 'record_type_id' => null,\n 'converted_at' => $convertedDate,\n 'converted_account_id' => $convertedAccount->id ?? null,\n 'converted_opportunity_id' => $convertedOpportunity->id ?? null,\n 'converted_contact_id' => $convertedContact->id ?? null,\n 'country_code' => $countryCode,\n 'remotely_created_at' => $createdDate,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->leads(), $crmData['Id']);\n\n /** @var Lead $lead */\n $lead = $this->config->leads()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n if ($lead->wasChanged('converted_at') && $lead->getConvertedAt() !== null) {\n $this->eventDispatcher->dispatch(new LeadConverted($lead));\n }\n\n $this->handleObjectDeletion($lead, $crmData);\n\n return $lead;\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccounts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n $fields = $this->getAllFieldsAsArray('account');\n\n if (\\in_array('Id', $fields, true) === false) {\n return $syncCount;\n }\n\n $query = '\n SELECT ' . rtrim(implode(',', $fields), ',') . '\n FROM Account\n WHERE LastModifiedDate > :since\n ORDER BY LastModifiedDate ASC';\n\n try {\n $sfAccounts = $this->queryHandler->query($query, [\n 'since' => $since->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($sfAccounts as $sfAccount) {\n // Only sync if previously imported.\n if ($this->hasAccount($sfAccount['Id'])) {\n $this->importAccount($sfAccount);\n $syncCount++;\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::ACCOUNT);\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccount(string $crmId): ?Account\n {\n $fields = $this->getAllFieldsAsArray('account');\n if (! in_array('Id', $fields, true)) {\n $this->logger->info('[Salesforce] Sync account cancelled. Fields are not available.', [\n 'crmId' => $crmId,\n 'userId' => $this->profile->getUserId(),\n ]);\n\n return null;\n }\n\n $sfAccount = $this->getRecord('Account', $crmId, $fields);\n\n return $this->importAccount($sfAccount);\n }\n\n private function importAccount($crmData): Account\n {\n $countryCode = $crmData['BillingCountryCode'] ?? $crmData['ShippingCountryCode'] ?? null;\n\n // Salesforce allows custom \"countries\" to be created. Disregard these.\n if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {\n $countryCode = null;\n }\n\n // If we have no country code, try to parse it from the country names.\n if ($countryCode === null && empty($crmData['BillingCountry']) === false) {\n $countryCode = $this->convertCountryNameToCode($crmData['BillingCountry']);\n }\n\n if ($countryCode === null && empty($crmData['ShippingCountry']) === false) {\n $countryCode = $this->convertCountryNameToCode($crmData['ShippingCountry']);\n }\n\n if (empty($crmData['Phone']) === false) {\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($crmData['Phone'], 0, 25);\n $parsedNumber = parsePhoneNumber($countryCode, $number);\n } else {\n $parsedNumber = [];\n }\n\n $industry = null;\n if (empty($crmData['Industry']) === false) {\n $industry = mb_strimwidth($crmData['Industry'], 0, 40);\n }\n\n $domain = null;\n if (empty($crmData['Website']) === false) {\n $domain = mb_strimwidth($crmData['Website'], 0, 191);\n $domain = StringUtil::resolveDomain($domain);\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $data = [\n 'team_id' => $this->team->id,\n 'user_id' => $profile?->user_id,\n 'owner_id' => $crmData['OwnerId'],\n 'name' => mb_strimwidth($crmData['Name'], 0, 191),\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['Id'],\n modelType: Account::class,\n fileName: $crmData['Id'],\n avatarText: $crmData['Name']\n ),\n 'industry' => $industry,\n 'domain' => $domain,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'ext' => $parsedNumber['ext'] ?? null,\n 'country_code' => $countryCode,\n 'remotely_created_at' => $createdDate,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->accounts(), $crmData['Id']);\n\n /** @var Account $account */\n $account = $this->config->accounts()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n $this->handleObjectDeletion($account, $crmData);\n\n return $account;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOpportunities(array $parameters, ?string $strategy = null): int\n {\n $strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);\n\n $syncCount = 0;\n $logParams = $parameters;\n $parameters['profile'] = $this->profile;\n $logParams['user'] = $this->profile->getUserId();\n\n if (count($strategies) > 1) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Multiple sync strategies used', [\n 'teamId' => $this->team->getUuid(),\n 'params' => $logParams,\n 'strategies_count' => count($strategies),\n ]);\n }\n\n foreach ($strategies as $syncStrategy) {\n $name = $syncStrategy->getStrategyName();\n\n try {\n $sfOpportunities = $syncStrategy->fetchOpportunities($parameters);\n $totalRecords = $sfOpportunities->count();\n\n foreach ($sfOpportunities as $sfOpportunity) {\n $this->importOpportunity($sfOpportunity);\n $syncCount++;\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n $this->logger->warning('[' . $this->getDisplayName() . '] No opportunities found', [\n 'teamId' => $this->team->getUuid(),\n 'name' => $name,\n 'params' => $logParams,\n 'reason' => $noResultsException->getMessage(),\n ]);\n } catch (CrmException $crmException) {\n // Nothing to sync.\n $this->logger->warning('[' . $this->getDisplayName() . '] Opportunity sync failed', [\n 'teamId' => $this->team->getUuid(),\n 'name' => $name,\n 'params' => $logParams,\n 'reason' => $crmException->getMessage(),\n ]);\n }\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::OPPORTUNITY, ['params' => $logParams]);\n\n // debug to see how if count of opportunities reaches 1000\n if ($syncCount >= 1000) {\n $this->logger->info(\n '[' . $this->getDisplayName() . '] Sync Opportunities - count warning',\n [\n 'team_id' => $this->team->getId(),\n 'params' => $logParams,\n 'count' => $syncCount,\n 'strategies_count' => count($strategies),\n 'total_records' => $totalRecords ?? null,\n ]\n );\n }\n\n return $syncCount;\n }\n\n\n /**\n * @inheritdoc\n */\n public function syncOpportunity(string $crmId): ?Opportunity\n {\n $strategy = $this->opportunitySyncStrategyResolver->resolve(\n $this->config,\n OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY\n );\n\n $parameters = [\n 'profile' => $this->profile,\n 'crm_id' => $crmId,\n ];\n\n try {\n $sfOpportunity = $strategy->fetchOpportunities($parameters);\n } catch (HttpNotFoundException $e) {\n $this->logger->info('[' . $this->getDisplayName() . '] Opportunity not found', [\n 'teamId' => $this->team->id_string,\n 'crmId' => $crmId,\n ]);\n\n return null;\n } catch (CrmException $crmException) {\n $this->logger->info('[' . $this->getDisplayName() . '] Opportunity sync failed', [\n 'teamId' => $this->team->id_string,\n 'crmId' => $crmId,\n 'exception' => $crmException->getMessage(),\n ]);\n\n return null;\n }\n\n if ($sfOpportunity instanceof ArrayIterator) {\n return $this->importOpportunity($sfOpportunity->getItems());\n }\n\n return $this->importOpportunity($sfOpportunity);\n }\n\n /**\n * @throws HttpNotFoundException\n */\n private function importOpportunity($crmData): ?Opportunity\n {\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $account = null;\n if (empty($crmData['AccountId']) === false) {\n /** @var ?Account $account */\n $account = $this->config->accounts()\n ->where('crm_provider_id', (string) $crmData['AccountId'])\n ->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmData['AccountId']);\n }\n }\n\n $userId = $profile?->getUserId() ?? $account?->getUserId();\n if ($userId === null) {\n $this->logger->error('[Salesforce] | Skip import, no user_id found', [\n 'id' => $crmData['Id'],\n ]);\n\n return null;\n }\n\n /** @var ?Stage $stage */\n $stage = null;\n if (isset($crmData['StageName'])) {\n $stage = $this->config\n ->stages()\n ->where('name', $crmData['StageName'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->orderBy('is_selectable', 'DESC')\n ->orderBy('id')\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $crmData['StageName']);\n }\n }\n\n $recordType = null;\n if (empty($crmData['RecordTypeId']) === false) {\n /** @var ?RecordType $recordType */\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $crmData['RecordTypeId'])\n ->first();\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $closeDate = null;\n if (empty($crmData['CloseDate']) === false) {\n $closeDate = Carbon::parse($crmData['CloseDate'])->format('Y-m-d');\n }\n\n $valueFieldName = 'Amount';\n if ($this->config->opportunity_value_field_id) {\n $valueFieldName = $this->config->opportunityValueField->crm_provider_id;\n }\n\n $data = [\n 'team_id' => $this->team->id,\n 'account_id' => $account->id ?? null,\n 'user_id' => $userId,\n 'owner_id' => $crmData['OwnerId'] ?? null,\n 'name' => mb_strimwidth($crmData['Name'] ?? '', 0, 128),\n 'value' => $crmData[$valueFieldName],\n 'currency_code' => CurrencyFormatter::formatCode($crmData['CurrencyIsoCode'] ?? null),\n 'close_date' => $closeDate,\n 'is_closed' => $crmData['IsClosed'],\n 'is_won' => $crmData['IsWon'],\n 'stage_id' => $stage?->id ?? null,\n 'record_type_id' => $recordType->id ?? null,\n 'remotely_created_at' => $createdDate,\n 'probability' => $crmData['Probability'] ?? null,\n 'forecast_category' => $crmData['ForecastCategoryName'] ?? null,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->opportunities(), $crmData['Id']);\n\n // Do not allow locked DB tables & other errors\n // to interrupt the process of reverting the trashed opportunities\n try {\n /** @var Opportunity $opportunity */\n $opportunity = $this->config->opportunities()\n ->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n // import external fields into crm_field_data if present\n $crmFields = $this->getOpportunitySyncableFields();\n\n $this->importOpportunityCrmFieldData($crmData, $crmFields, $opportunity->id);\n\n $this->handleObjectDeletion($opportunity, $crmData);\n\n if ($opportunity->wasRecentlyCreated) {\n MatchActivitiesToNewOpportunity::dispatch($opportunity->getId());\n }\n\n return $opportunity;\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n $this->logger->error('[Salesforce] importOpportunity failure.', [\n 'crm_provider_id' => $crmData['Id'],\n 'team_id' => $this->team->id,\n 'exception' => $exception->getMessage(),\n ]);\n\n $this->handleEntityDeletionByProviderId($this->config->opportunities(), $crmData);\n }\n\n return null;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContacts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n $fields = $this->getAllFieldsAsArray('contact');\n if (\\in_array('Id', $fields, true) === false) {\n return $syncCount;\n }\n\n $query = '\n SELECT ' . rtrim(implode(',', $fields), ',') . '\n FROM Contact\n WHERE LastModifiedDate > :since\n ORDER BY LastModifiedDate ASC';\n\n try {\n $sfContacts = $this->queryHandler->query($query, [\n 'since' => $since->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($sfContacts as $sfContact) {\n // Only sync if previously imported.\n if ($this->hasContact($sfContact['Id'])) {\n $this->importContact($sfContact);\n $syncCount++;\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::CONTACT);\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContact(string $crmId): ?Contact\n {\n $fields = $this->getAllFieldsAsArray('contact');\n if (! in_array('Id', $fields, true)) {\n $this->logger->info('[Salesforce] Sync contact cancelled. Fields are not available.', [\n 'crmId' => $crmId,\n 'userId' => $this->profile->getUserId(),\n ]);\n\n return null;\n }\n\n $sfContact = $this->getRecord('Contact', $crmId, $fields);\n\n return $this->importContact($sfContact);\n }\n\n private function importContact($crmData): Contact\n {\n $account = null;\n // Contacts may not have accounts...\n if (isset($crmData['AccountId'])) {\n $account = $this->config->accounts()\n ->where('crm_provider_id', (string) $crmData['AccountId'])\n ->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmData['AccountId']);\n }\n }\n\n $countryCode = $crmData['MailingCountryCode'] ?? null;\n\n // Salesforce allows custom \"countries\" to be created. Disregard these.\n if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {\n $countryCode = null;\n }\n\n // If we have no country code, try to parse it from the country name.\n if ($countryCode === null && empty($crmData['MailingCountry']) === false) {\n $countryCode = $this->convertCountryNameToCode($crmData['MailingCountry']);\n\n if ($countryCode === null && $account) {\n $countryCode = $account->country_code;\n }\n }\n\n $ext = null;\n $parsedNumber = [];\n if (empty($crmData['Phone']) === false) {\n $number = Str::limit($crmData['Phone'], 25, '');\n $parsedNumber = parsePhoneNumber($countryCode, $number);\n\n if (empty($parsedNumber['ext']) === false) {\n $ext = Str::limit($parsedNumber['ext'], 10, '');\n }\n }\n\n $mobileNumber = null;\n if (empty($crmData['MobilePhone']) === false) {\n $mobileNumber = Str::limit(phone_e164($countryCode, $crmData['MobilePhone']), 25, '');\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $data = [\n 'team_id' => $this->team->id,\n 'account_id' => $account->id ?? null,\n 'user_id' => $profile?->user_id,\n 'owner_id' => $crmData['OwnerId'] ?? null,\n 'name' => ($crmData['Name'] ?? null) !== null ? mb_strimwidth($crmData['Name'], 0, 100) : '',\n 'title' => ($crmData['Title'] ?? null) !== null ? mb_strimwidth($crmData['Title'], 0, 128) : null,\n 'email' => ($crmData['Email'] ?? null) !== null ? mb_strimwidth($crmData['Email'], 0, 191) : null,\n 'country_code' => $countryCode,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'ext' => $ext,\n 'mobile_phone' => $mobileNumber,\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['Id'],\n modelType: Contact::class,\n fileName: $crmData['Id'],\n avatarText: $crmData['Name']\n ),\n 'remotely_created_at' => $createdDate,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->contacts(), $crmData['Id']);\n\n /** @var Contact $contact */\n $contact = $this->config->contacts()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n $this->handleObjectDeletion($contact, $crmData);\n\n return $contact;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n $fields = [\n 'InstanceName',\n 'OrganizationType',\n 'IsSandbox',\n ];\n\n $orgValues = $this->getRecord('Organization', $this->config->crm_provider_id, $fields);\n\n $edition = null;\n switch ($orgValues['OrganizationType']) {\n case 'Developer Edition':\n $edition = Configuration::EDITION_DEVELOPER;\n\n break;\n\n case 'Professional Edition':\n $edition = Configuration::EDITION_PROFESSIONAL;\n\n break;\n\n case 'Enterprise Edition':\n $edition = Configuration::EDITION_ENTERPRISE;\n\n break;\n }\n\n $this->config->edition = $edition;\n $this->config->instance = $orgValues['InstanceName'];\n\n // XXX: How can this state be possible?\n if ($this->config->version === null) {\n $this->config->version = Client::MIN_API_VERSION;\n }\n\n $installedVersion = $this->getInstalledAppVersion();\n if ($installedVersion !== null) {\n $installedVersion = (string) $this->getInstalledAppVersion();\n }\n\n $this->config->installed_app_version = $installedVersion;\n\n $this->config->save();\n }\n\n public function getInstalledAppVersion(): ?string\n {\n try {\n $query = '\n SELECT\n SubscriberPackageVersion.MajorVersion,\n SubscriberPackageVersion.MinorVersion,\n SubscriberPackageVersion.PatchVersion,\n SubscriberPackageVersion.BuildNumber\n FROM\n InstalledSubscriberPackage\n WHERE\n SubscriberPackageId = :packageId\n ';\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'packageId' => self::INSTALLED_PACKAGE_ID,\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n // Grab version number.\n $version = $sfField['SubscriberPackageVersion']['MajorVersion'] .\n $sfField['SubscriberPackageVersion']['MinorVersion'] .\n $sfField['SubscriberPackageVersion']['PatchVersion'] .\n $sfField['SubscriberPackageVersion']['BuildNumber'];\n } catch (\\Exception) {\n $version = null;\n }\n\n return $version;\n }\n\n /**\n * Store transcripts as note.\n *\n * @throws \\Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // For SF we also check if Log Notes is enabled.\n if ($this->profile->log_notes === Profile::LOG_NOTE_NONE) {\n return;\n }\n\n if ($activity->opportunity_id && $activity->prospect === null) {\n return;\n }\n\n try {\n $transcriptionData = $this->generateTranscription($activity);\n\n $noteMaxLength = $this->profile->log_notes === Profile::LOG_NOTE_ENHANCED\n ? self::ENHANCED_NOTE_MAX_LENGTH\n : self::CLASSIC_NOTE_MAX_LENGTH;\n\n $title = 'Transcript for ';\n $title .= $activity->title ?? $activity->activity_title;\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $body = mb_strimwidth($transcriptionData, 0, $noteMaxLength);\n\n if ($activity->opportunity_id) {\n $objectId = $activity->opportunity->crm_provider_id;\n } else {\n $objectId = $activity->prospect->crm_provider_id;\n }\n\n $noteId = $this->saveNote($title, $body, $objectId);\n\n // Store crm logged id in transcription.\n $transcription = $activity->getTranscription();\n $transcription->crm_activity_id = $noteId;\n $transcription->save();\n } catch (\\Exception $e) {\n \\Sentry::captureException($e);\n }\n }\n\n public function saveNote(string $title, string $body, string $objectId, ?NoteObject $noteObject = null): ?string\n {\n $noteId = null;\n\n try {\n if ($this->profile->log_notes === Profile::LOG_NOTE_ENHANCED) {\n $noteId = $this->buildEnhancedNote($title, $body, $objectId);\n } else {\n $noteId = $this->buildClassicNote($title, $body, $objectId);\n }\n } catch (HttpNotFoundException $exception) {\n // The profile not having access to create Enhanced Notes. Set their preference to Classic.\n if ($this->profile->log_notes === Profile::LOG_NOTE_ENHANCED) {\n $this->profile->update([\n 'log_notes' => Profile::LOG_NOTE_CLASSIC,\n ]);\n }\n }\n\n return $noteId;\n }\n\n /**\n * This is using the \"Enhanced\" Notes feature, NOT the \"Notes & Attachments\" feature being deprecated.\n *\n * @url https://salesforce.stackexchange.com/questions/104408/how-can-i-create-an-account-note-or-contact-note-via-api-that-is-visible-in-sale\n */\n private function buildEnhancedNote(string $title, string $body, string $objectId): string\n {\n // Decode stored entities, escape HTML (without quoting), then convert line breaks for Salesforce formatting\n $decodedBody = html_entity_decode($body, ENT_QUOTES | ENT_HTML5);\n $sanitizedBody = htmlspecialchars($decodedBody, ENT_NOQUOTES, 'UTF-8', false);\n $content = nl2br($sanitizedBody, false);\n $note = [\n 'OwnerId' => $this->profile->crm_provider_id,\n 'Title' => $title,\n 'Content' => base64_encode($content),\n ];\n\n $noteId = $this->createRecord('ContentNote', $note);\n\n $link = [\n 'ContentDocumentId' => $noteId,\n 'LinkedEntityId' => $objectId,\n 'ShareType' => 'I',\n ];\n\n $this->createRecord('ContentDocumentLink', $link);\n\n return $noteId;\n }\n\n private function buildClassicNote(string $title, string $body, string $objectId): string\n {\n if (in_array($this->parseObjectType($objectId), [Field::OBJECT_TASK, Field::OBJECT_EVENT])) {\n $this->logger->info('[Salesforce] Summary not sent', [\n 'profile_id' => $this->profile->id,\n 'objectId' => $objectId,\n 'reason' => 'Classical Note does not support Task/Event relation',\n ]);\n\n return '';\n }\n\n $titleTrimmed = null;\n\n if (mb_strlen($title) > 80) {\n $titleTrimmed = substr($title, 0, 77) . '...';\n }\n $payload = [\n 'OwnerId' => $this->profile->crm_provider_id,\n 'IsPrivate' => false,\n 'Title' => $titleTrimmed ?? $title,\n 'Body' => $titleTrimmed ? $title . PHP_EOL . $body : $body,\n 'ParentId' => $objectId,\n ];\n\n return $this->createRecord('Note', $payload);\n }\n\n /**\n * @inheritdoc\n */\n public function find(string $name, array $scopes): array\n {\n if ($this->profile === null) {\n return [];\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $limitValues = ['limit' => $this->limit, 'offset' => $this->offset];\n $sosl = $queryBuilder->buildFindQuery($name, $scopes, $limitValues);\n\n $this->logger->info('[Salesforce] Find prospects', [\n 'profile_id' => $this->profile->id,\n 'sosl_query' => $sosl,\n 'search_string' => $name,\n 'scopes' => $scopes,\n ]);\n\n $data = Cache::remember($this->profile->id . $sosl, self::CACHE_TTL, function () use ($sosl) {\n $data = [];\n\n try {\n // Hit remote API.\n $objects = $this->queryHandler->search($sosl);\n\n // Build mapped list.\n foreach ($objects as $object) {\n $type = strtolower($object['attributes']['type']);\n\n $record = [\n 'crmId' => $object['Id'],\n 'name' => $object['Name'],\n 'prospectType' => $type,\n 'phoneNumbers' => [],\n 'crmUrl' => $this->generateProviderUrl($object['Id'], $type),\n ];\n\n switch ($type) {\n case 'lead':\n if (empty($object['Company']) === false) {\n $record['organization'] = $object['Company'];\n }\n\n if (empty($object['Title']) === false) {\n $record['title'] = $object['Title'];\n }\n\n $stage = $this->config->stages()\n ->where('type', Stage::TYPE_LEAD)\n ->where('name', $object['Status'])\n ->first();\n\n // Lazy create the stage.\n if ($stage === null) {\n $stage = $this->importStages([Stage::TYPE_LEAD], $object['Status']);\n }\n\n if ($stage) {\n $record += [\n 'stage' => [\n 'id' => $stage->id_string,\n 'name' => $stage->name,\n ],\n ];\n }\n\n if (empty($object['RecordTypeId']) === false) {\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $object['RecordTypeId'])\n ->first();\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n }\n\n break;\n\n case 'account':\n if (empty($object['Industry']) === false) {\n $record['industry'] = $object['Industry'];\n $record['detailsLine'] = $object['Industry'];\n }\n if (! empty($object['PersonEmail'])) {\n $record['detailsLine'] = $object['PersonEmail'];\n }\n\n break;\n\n case 'contact':\n // For contacts, we should try and fetch their account name too.\n if ($object['AccountId']) {\n // Cheaper to get this locally.\n $account = $this->config->accounts()\n ->where('crm_provider_id', $object['AccountId'])\n ->first(['name']);\n\n if ($account) {\n $record['organization'] = $account->name;\n }\n }\n\n if (! empty($object['IsPersonAccount']) && $object['Email']) {\n $record['detailsLine'] = $object['Email'];\n } else {\n if (empty($object['Title']) === false) {\n $record['title'] = $object['Title'];\n }\n }\n\n break;\n }\n\n // Add phone numbers to record.\n if (empty($object['Phone']) === false && $object['Phone']) {\n $record['phoneNumbers'][] = [\n 'number' => $object['Phone'],\n 'nationalFormat' => phone_national($this->profile->user->country_code, $object['Phone']),\n 'type' => 'phone',\n ];\n }\n\n if (empty($object['MobilePhone']) === false && $object['MobilePhone']) {\n $record['phoneNumbers'][] = [\n 'number' => $object['MobilePhone'],\n 'nationalFormat' => phone_national(\n $this->profile->user->country_code,\n $object['MobilePhone']\n ),\n 'type' => 'mobile',\n ];\n }\n\n $data[] = $record;\n }\n } catch (NoResultsException $e) {\n $data = [];\n }\n\n return $data;\n });\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n\n if ($crmAccountId === null) {\n return $data;\n }\n\n if ($userId) {\n $profileRepository = app(ProfileRepository::class);\n $profile = $profileRepository->findProfileByUserId($this->config, $userId);\n\n $ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;\n }\n\n try {\n // Perhaps their profile has no opportunity permissions.\n if ($this->profile === null || $this->profile->opportunity_fields === null) {\n return $data;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $query = $queryBuilder->buildFindOpportunitiesQuery();\n\n $objects = $this->queryHandler->query($query, ['accountId' => $crmAccountId]);\n\n foreach ($objects as $object) {\n $record = [\n 'crmId' => $object['Id'],\n 'name' => $object['Name'],\n 'won' => $object['IsWon'],\n 'closed' => $object['IsClosed'],\n ];\n\n $valueFieldName = 'Amount';\n if ($this->config->opportunity_value_field_id) {\n $valueFieldName = $this->config->opportunityValueField->crm_provider_id;\n }\n\n if (empty($object[$valueFieldName]) === false) {\n $currency = $object['CurrencyIsoCode'] ?? $this->config->default_currency;\n $value = formatCurrency($object[$valueFieldName], $currency);\n\n $record += [\n 'value' => $value,\n ];\n }\n\n $stage = $this->config->stages()\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->where('name', $object['StageName'])\n ->first();\n\n // Lazy create the stage.\n if ($stage === null) {\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $object['StageName']);\n }\n\n $record += [\n 'stage' => [\n 'id' => $stage->id_string,\n 'name' => $stage->name,\n ],\n ];\n\n if (empty($object['RecordTypeId']) === false) {\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $object['RecordTypeId'])\n ->first();\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n }\n\n if ($ownerId && isset($object['OwnerId']) && $object['OwnerId'] === $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n } catch (NoResultsException $e) {\n return $data;\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n public function getContactRolesFromCrm(?Carbon $since = null): array\n {\n $roles = [];\n\n if ($this->profile === null) {\n return $roles;\n }\n\n $queryBuilder = app(QueryBuilder::class, ['profile' => $this->profile]);\n\n $query = $queryBuilder->buildGetContactRolesQuery($since);\n\n try {\n $objects = $this->queryHandler->query($query);\n\n foreach ($objects as $object) {\n $roles[] = [\n 'id' => $object['Id'],\n 'contactId' => $object['ContactId'],\n 'opportunityId' => $object['OpportunityId'],\n 'ownerId' => $object['Opportunity']['OwnerId'] ?? null,\n 'isPrimary' => $object['IsPrimary'],\n 'role' => $object['Role'],\n ];\n }\n } catch (NoResultsException) {\n // Just return an empty array.\n $this->logger->info('[Salesforce] No contact roles found', [\n 'since' => $since?->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n }\n\n return $roles;\n }\n\n public function syncContactRoles(Carbon $since): int\n {\n $contactRoleRepository = app(ContactRoleRepository::class);\n\n $crmContactRoles = $this->getContactRolesFromCrm(since: $since);\n $syncCount = 0;\n $contactRoles = [];\n\n foreach ($crmContactRoles as $crmContactRole) {\n $contactRoles[] = $this->importContactRole($crmContactRole);\n $syncCount++;\n }\n\n $contactRoleRepository->saveContactRoles($contactRoles);\n\n $this->syncRemotelyDeletedContactRoles();\n\n return $syncCount;\n }\n\n private function importContactRole(array $contactRole): array\n {\n $contact = $this->config->contacts()\n ->where('crm_provider_id', $contactRole['contactId'])\n ->first();\n\n if ($contact === null) {\n $contact = $this->syncContact($contactRole['contactId']);\n }\n\n $opportunity = $this->config->opportunities()\n ->where('crm_provider_id', $contactRole['opportunityId'])\n ->first();\n\n if ($opportunity === null) {\n $opportunity = $this->syncOpportunity($contactRole['opportunityId']);\n }\n\n $role = null;\n if (! empty($contactRole['role'])) {\n $role = mb_strimwidth($contactRole['role'], 0, 191);\n }\n\n return [\n 'crm_configuration_id' => $this->config->getId(),\n 'contact_id' => $contact->getId(),\n 'crm_provider_id' => $contactRole['id'],\n 'subject_type' => ContactRole::SUBJECT_TYPE_OPPORTUNITY,\n 'subject_id' => $opportunity->getId(),\n 'is_primary' => $contactRole['isPrimary'],\n 'role' => $role,\n ];\n }\n\n protected function syncRemotelyDeletedContactRoles(): bool\n {\n try {\n $deletedRemotely = $this->queryHandler->queryDeleted('OpportunityContactRole');\n } catch (NoResultsException $e) {\n return false;\n }\n\n $deletedOpportunities = $deletedRemotely->getResults();\n $deletedIds = array_column($deletedOpportunities, 'id');\n\n $contactRoleRepository = app(ContactRoleRepository::class);\n\n foreach (array_chunk($deletedIds, self::HARD_DELETE_CHUNK) as $chunk) {\n $contactRoleRepository->deleteContactRoles($chunk);\n\n $this->logger->info('[' . $this->getDisplayName() . '] Remotely deleted opportunities synced', [\n 'teamId' => $this->team->id_string,\n 'remotelyDeletedOpportunities' => $chunk,\n 'count' => count($chunk),\n ]);\n }\n\n return true;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = $objects = [];\n\n $hasWho = \\in_array($objectType, ['lead', 'contact']);\n $playbook = $this->getPlaybook($this->profile->user);\n $fields = array_merge(\n $this->profile->getFieldsAsArray(parent::OBJECT_TASK),\n $playbook && $playbook->activityField ? [$playbook->activityField->crm_provider_id] : []\n );\n\n // Query should default to any open call for that user.\n $query = '\n SELECT ' . implode(',', array_unique($fields)) . '\n FROM Task\n WHERE OwnerId = :ownerId\n AND IsArchived = false\n AND IsDeleted = false\n AND IsClosed = false\n AND (';\n\n if ($objectType === 'account') {\n // This covers tasks tied to a related contact or opportunity too.\n $query .= '\n AccountId = :accountId';\n }\n\n if ($hasWho) {\n $query .= '\n WhoId = :whoId';\n\n // If we are also going to check on a specific opportunity, set that up.\n if ($opportunityId) {\n $query .= ' OR WhatId = :whatId';\n }\n }\n\n $query .= ' ) ORDER BY LastModifiedDate DESC';\n\n try {\n $objects = $this->queryHandler->query($query, [\n 'ownerId' => $this->profile->crm_provider_id,\n 'whoId' => $objectId,\n 'whatId' => $opportunityId,\n 'accountId' => $objectId,\n ]);\n } catch (NoResultsException $e) {\n return $data;\n } finally {\n $this->logger->debug(sprintf('[Salesforce] Found %s tasks for query \"%s\"', count($objects), $query));\n }\n\n foreach ($objects as $object) {\n $dueDate = $object['ActivityDate'] ? Carbon::parse($object['ActivityDate'])->toIso8601String() : null;\n $data[] = [\n 'crmId' => $object['Id'],\n 'subject' => $object['Subject'],\n 'due' => $dueDate,\n 'type' => $object[$playbook->activityField->crm_provider_id],\n ];\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getEvents(string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = $objects = [];\n $user = $this->profile?->user;\n if ($this->profile === null || $user === null) {\n return $data;\n }\n\n $hasWho = \\in_array($objectType, ['lead', 'contact']);\n $playbook = $this->getPlaybook($user);\n $fields = array_merge(\n $this->profile->getFieldsAsArray(parent::OBJECT_EVENT),\n $playbook && $playbook->activityField ? [$playbook->activityField->crm_provider_id] : []\n );\n\n // Query should default to any event starting in the last week and ending up until today owned by the user.\n $query = '\n SELECT ' . implode(',', array_unique($fields)) . '\n FROM Event\n WHERE OwnerId = :ownerId\n AND IsArchived = false\n AND IsAllDayEvent = false\n AND StartDateTime >= LAST_N_DAYS:7\n AND EndDateTime <= TODAY\n AND (';\n\n if ($objectType === 'account') {\n // This covers events tied to a related contact or opportunity too.\n $query .= '\n AccountId = :accountId';\n }\n\n if ($hasWho) {\n $query .= '\n WhoId = :whoId';\n\n // If we are also going to check on a specific opportunity, set that up.\n if ($opportunityId) {\n $query .= ' OR WhatId = :whatId';\n }\n }\n\n $query .= ' ) ORDER BY LastModifiedDate DESC';\n\n try {\n $objects = $this->queryHandler->query($query, [\n 'ownerId' => $this->profile->crm_provider_id,\n 'whoId' => $objectId,\n 'whatId' => $opportunityId,\n 'accountId' => $objectId,\n ]);\n } catch (NoResultsException $e) {\n return $data;\n } finally {\n $this->logger->debug(sprintf('[Salesforce] Found %s tasks for query \"%s\"', count($objects), $query));\n }\n\n foreach ($objects as $object) {\n $dueDate = $object['StartDateTime'] ? Carbon::parse($object['StartDateTime'])->toIso8601String() : null;\n\n $data[] = [\n 'crmId' => $object['Id'],\n 'subject' => $object['Subject'],\n 'due' => $dueDate,\n 'type' => $object[$playbook->activityField->crm_provider_id],\n ];\n }\n\n return $data;\n }\n\n /**\n * Try to find CRM Objects using email address\n *\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchExactlyByEmail(string $email, ?int $userId = null): ?array\n {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $sosl = $queryBuilder->buildMatchByQuery($email, Field::TYPE_EMAIL);\n if ($sosl === null) {\n return null;\n }\n\n try {\n $objects = $this->queryHandler->search($sosl);\n $objects = $this->queryHandler->prioritiseResults(\n $objects,\n $email,\n QueryHandler::PRIORITISE_EMAIL\n );\n\n $data = $this->convertCrmData($objects, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (NoResultsException $e) {\n // Try the account next.\n if ($this->profile->account_fields === null) {\n return null;\n }\n }\n\n return null;\n }\n\n public function getDomain(string $email): ?string\n {\n // SF improved search - strip the domain extension, min domain name length 4\n return $this->getCompanyNameFromEmail(email: $email, minNameLength: 4);\n }\n\n /**\n * Try to find CRM objects using domain name of the email address\n *\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByDomain(string $domain, ?int $userId = null): ?array\n {\n $companyName = $domain;\n\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $sosl = $queryBuilder->buildMatchByDomainQuery($companyName);\n\n try {\n $objects = $this->queryHandler->search($sosl);\n\n $data = $this->convertCrmData($objects, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (NoResultsException) {\n return null;\n }\n }\n\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n // Don't bother looking up numbers that are masked.\n if (str_contains($phone, '**')) {\n return null;\n }\n\n if ($this->isPhoneNumberOfTeamMember($phone)) {\n return null;\n }\n\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $phoneNational = phone_national(null, $phone) ?? '';\n $possiblePhoneFormats = collect([\n preg_replace('/\\D/', '', ltrim($phone, '0+')),\n preg_replace('/\\D/', '', $phoneNational),\n formatDashPhoneNumber($phone),\n $phoneNational,\n ])\n ->filter() // Removes null and empty strings\n ->unique()\n ->values();\n\n foreach ($possiblePhoneFormats as $phone) {\n $sosl = $queryBuilder->buildMatchByQuery($phone, Field::TYPE_PHONE);\n if ($sosl === null) {\n continue;\n }\n\n try {\n $objects = $this->queryHandler->search($sosl);\n $objects = $this->queryHandler->prioritiseResults(\n $objects,\n $phone,\n QueryHandler::PRIORITISE_PHONE\n );\n\n return $this->convertCrmData($objects, $userId);\n } catch (NoResultsException) {\n continue;\n }\n }\n\n return null;\n }\n\n private function isPhoneNumberOfTeamMember(string $phone): bool\n {\n $teamRepository = app(TeamRepository::class);\n $user = $teamRepository->findTeamMemberByPhone($this->team, $phone);\n\n if ($user instanceof User) {\n return true;\n }\n\n return false;\n }\n\n protected function getCacheKey(string $object, ?int $userId = null): ?string\n {\n $key = $this->profile->id . $object;\n $keySuffix = $this->getOwnerKeySuffix($userId);\n\n return $key . $keySuffix;\n }\n\n private function getOwnerKeySuffix(?int $userId = null): string\n {\n return $userId === null ? '' : (string) $userId;\n }\n\n /** Determine the CRM Objects which represent the call activity. */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n if ($this->profile === null) {\n return null;\n }\n\n $cacheKey = $this->getCacheKey($name, $userId);\n\n $result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $sosl = $queryBuilder->buildMatchByQuery($name, 'name');\n if ($sosl === null) {\n return false;\n }\n\n try {\n $objects = $this->queryHandler->search($sosl);\n } catch (NoResultsException $e) {\n return false;\n }\n\n $objects = $this->queryHandler->prioritiseResults(\n $objects,\n $name,\n QueryHandler::PRIORITISE_NAME\n );\n\n $data = $this->convertCrmData($objects, $userId);\n\n return (! empty(array_filter($data))) ? $data : false;\n });\n\n return is_array($result) ? $result : null;\n }\n\n /**\n * @return array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n protected function convertCrmData(QueryIterator $objects, ?int $userId = null): array\n {\n $lead = null;\n $contact = null;\n $opportunity = null;\n $account = null;\n $stage = null;\n $countryCode = null;\n\n if ($objects->count() > 0) {\n $object = $objects->current();\n\n if ($object['attributes']['type'] === 'Lead') {\n $lead = $this->importLead($object);\n\n // Lead might not be imported if the Stage is null for example.\n if ($lead) {\n $countryCode = $lead->country_code;\n $stage = $lead->stage;\n }\n } else {\n if ($object['attributes']['type'] === 'Contact') {\n $contact = $this->importContact($object);\n $account = $contact->account;\n } else {\n $account = $this->importAccount($object);\n }\n\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account) {\n $countryCode = $account->country_code;\n }\n\n try {\n $sfOpportunities = $this->findOpportunities(\n $account?->getCrmProviderId(),\n $contact?->getCrmProviderId(),\n $userId\n );\n\n // Take the first opportunity, which will be ordered as priority based on their settings.\n if (! empty($sfOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($sfOpportunities[0]['crmId']);\n $stage = $opportunity?->stage;\n }\n } catch (Exception) {\n // Nothing to see here.\n }\n }\n }\n\n return [\n $lead,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n /**\n * @inheritdoc\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n if ($stage->type === Stage::TYPE_LEAD) {\n $objectType = 'Lead';\n $objectId = $crmObject->crm_provider_id;\n $objectStageType = 'Status';\n } else {\n $objectType = 'Opportunity';\n $objectId = $crmObject->crm_provider_id;\n $objectStageType = 'StageName';\n }\n\n $headers = [];\n if ($this->config->trigger_assignment_rules === false) {\n // @see: https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/headers_autoassign.htm\n $headers = [\n 'Sforce-Auto-Assign' => 'false',\n ];\n }\n\n $this->updateRecord($objectType, $objectId, [$objectStageType => $stage->name], $headers);\n }\n\n public function parseObjectType(string $objectId): string\n {\n if (Str::startsWith($objectId, '001')) {\n return 'account';\n }\n\n if (Str::startsWith($objectId, '003')) {\n return 'contact';\n }\n\n if (Str::startsWith($objectId, '00Q')) {\n return 'lead';\n }\n\n if (Str::startsWith($objectId, '006')) {\n return 'opportunity';\n }\n\n if (Str::startsWith($objectId, '00U')) {\n return 'event';\n }\n\n if (Str::startsWith($objectId, '00T')) {\n return 'task';\n }\n\n throw new \\InvalidArgumentException('Unsupported Object Type');\n }\n\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, ['profile' => $this->profile]);\n $query = $queryBuilder->buildGetUsersQuery($userToSearch);\n\n try {\n $salesforceUsers = $this->queryHandler->query($query, [\n 'active' => true,\n ]);\n } catch (NoResultsException $e) {\n $this->logger->info('[Salesforce] Sync Profiles. No users found', [\n 'query' => $query,\n 'error' => $e->getMessage(),\n ]);\n\n return null;\n }\n\n $teamRepository = app(TeamRepository::class);\n $customRules = $this->getCustomProfileRules($teamRepository);\n\n foreach ($salesforceUsers as $crmUser) {\n if ($crmUser['Email'] === null) {\n continue;\n }\n\n if (! $this->customProfileValidation($crmUser, $customRules)) {\n continue;\n }\n\n $user = $teamRepository->findActiveTeamMemberByEmail($this->team, $crmUser['Email']);\n\n if (! $user instanceof User) {\n continue;\n }\n\n $edition = $crmUser['UserPreferencesLightningExperiencePreferred']\n ? Profile::EDITION_LIGHTNING\n : Profile::EDITION_CLASSIC;\n\n $profileRepository = app(ProfileRepository::class);\n $profile = $profileRepository->updateOrCreateProfile(\n $user,\n [\n 'crm_configuration_id' => $this->config->getId(),\n 'crm_provider_id' => $crmUser['Id'],\n ],\n [\n 'user_id' => $user->getId(),\n 'edition' => $edition,\n 'has_external_cti' => ! empty($crmUser['CallCenterId']),\n 'crm_profile_id' => $crmUser['ProfileId'],\n ]\n );\n\n if ($userToSearch instanceof User && $userToSearch->getId() === $user->getId()) {\n return $profile;\n }\n }\n\n // Clean up inactive profiles\n try {\n $this->archiveInactiveProfiles();\n } catch (\\Exception $e) {\n $this->logger->warning('[Salesforce] Profile archiving failed', [\n 'teamId' => $this->team->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $url = null;\n\n // For Salesforce it's easy, we just point every object to the apex domain and they handle it.\n switch ($objectType) {\n case 'lead':\n case 'account':\n case 'contact':\n case 'opportunity':\n case 'task':\n case 'event':\n case 'activity':\n\n $url = $this->config->crm_base_url . '/' . $providerId;\n\n break;\n }\n\n return $url;\n }\n\n public function buildTaskSearchFields(): array\n {\n return ['Id', 'WhoId', 'WhatId', 'AccountId'];\n }\n\n public function getTaskByFilterConditions(\n array $fields,\n array $filters,\n bool $bulkSearch = false,\n bool $strictFilters = true\n ): ?array {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $query = $queryBuilder->buildSearchTaskQuery($fields, $filters, $bulkSearch, $strictFilters);\n\n try {\n if (! $bulkSearch) {\n $objects = $this->queryHandler->query($query, $filters);\n if ($objects->count() === 1) {\n return $objects->current();\n }\n }\n\n if ($bulkSearch) {\n $objects = $this->queryHandler->query($query);\n $records = [];\n foreach ($objects as $record) {\n $key = $record[end($fields)];\n $records[$key] = $record;\n }\n\n return $records;\n }\n } catch (\\Exception $e) {\n $this->logger->info('[Salesforce] Failed to execute query', [\n 'query' => $query,\n 'error' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function mapCrmObjects(array $task): array\n {\n $activityData = [];\n\n if (! empty($task['WhoId'])) {\n $type = $this->parseObjectType($task['WhoId']);\n $activityData[$type] = $task['WhoId'];\n }\n if (! empty($task['AccountId'])) {\n $activityData['account'] = $task['AccountId'];\n }\n if (! empty($task['WhatId'])) {\n $activityData['opportunity'] = $task['WhatId'];\n }\n\n return $activityData;\n }\n\n /**\n * Get SF task by Outreach call id.\n */\n public function getTaskByFilter(\n string $activityFieldType,\n array $filters,\n string $operator = '=',\n array $additionalFields = []\n ): ?array {\n $data = [];\n\n try {\n // Default (base) fields.\n $fields = ['Id', 'Subject', 'Description', 'ActivityDate', 'WhoId', 'WhatId', $activityFieldType];\n\n foreach ($additionalFields as $additionalField) {\n $fields[] = $additionalField->crm_provider_id;\n }\n\n $fields = array_unique($fields);\n\n // Find task with the same Outreach id as the call id.\n $query = 'SELECT ' . implode(',', $fields) . '\n FROM Task\n WHERE IsArchived = false AND IsDeleted = false';\n\n foreach ($filters as $key => $value) {\n $key = preg_quote($key, '/');\n $key = str_replace(['\\'', '\"'], '', $key);\n // Prepare the substitution.\n $strKey = \":$key\";\n\n $query .= \" AND $key $operator $strKey\";\n }\n\n $query .= ' ORDER BY LastModifiedDate DESC LIMIT 1';\n\n $objects = $this->queryHandler->query($query, $filters);\n\n // There should be only one task related to this call if any.\n if ($objects->count() === 1) {\n $object = $objects->current();\n\n $dueDate = $object['ActivityDate'] ? Carbon::parse($object['ActivityDate'])->toIso8601String() : null;\n\n $data = array_merge($object, [\n 'crmId' => $object['Id'],\n 'subject' => $object['Subject'],\n 'summary' => $object['Description'],\n 'due' => $dueDate,\n 'Type' => $object[$activityFieldType],\n ]);\n }\n } catch (NoResultsException $e) {\n // Filters don't match any records.\n } catch (ServiceUnavailableException $serviceUnavailableException) {\n // Service cannot be queried. We should probably log this.\n }\n\n return $data;\n }\n\n /**\n * Get Salesforce fields including datetime fields\n *\n * @param $objectType\n */\n private function getAllFieldsAsArray($objectType): array\n {\n $basicFields = [];\n // Not all users have access to all object fields.\n if ($this->profile->{$objectType . '_fields'}) {\n $basicFields = explode(',', $this->profile->{$objectType . '_fields'});\n }\n\n $extraFields = [\n 'CreatedDate',\n 'LastModifiedDate',\n 'IsDeleted',\n ];\n\n if ($objectType === self::OBJECT_OPPORTUNITY\n && $this->config->opportunity_value_field_id\n && ! in_array($this->config->opportunityValueField->crm_provider_id, $basicFields)\n ) {\n $extraFields[] = $this->config->opportunityValueField->crm_provider_id;\n }\n\n return array_unique(array_merge($basicFields, $extraFields));\n }\n\n /**\n * Generate transcription for activity description.\n */\n private function generateTranscription(Activity $activity): string\n {\n if (! ($this->config->store_transcript)) {\n // If sending transcription to activity toggle is disabled\n return '';\n }\n\n return $this->transcriptionService\n ->findTranscriptionByActivity($activity)\n ->map(static function (array $transcriptionSegment): string {\n return $transcriptionSegment['formattedStartsAt'] . ' | ' . $transcriptionSegment['transcript'];\n })\n ->implode(PHP_EOL);\n }\n\n /**\n * Find related Salesforce event based on activity data\n *\n * @return array<string>\n */\n public function fetchRelatedActivity(Activity $activity): array\n {\n $this->logger->info('[Salesforce] Searching for related activity', [\n 'activityId' => $activity->getUuid(),\n 'ownerId' => $this->profile?->crm_provider_id,\n ]);\n\n $sfEvent = $this->fetchRelatedEvent($activity);\n if (empty($sfEvent)) {\n $this->logger->info('[Salesforce] No related activity found', [\n 'activityId' => $activity->getUuid(),\n 'ownerId' => $this->profile?->crm_provider_id,\n 'account' => $activity->hasAccount()\n ? $activity->getAccount()->getCrmProviderId()\n : null,\n ]);\n\n return [];\n }\n\n return $sfEvent;\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n if ($activity->isTypeConference() === false) {\n return null;\n }\n\n if ($activity->hasActualStartTime() === false && $activity->hasScheduledStartTime() === false) {\n return null;\n }\n\n if (! $activity->hasProspect()) {\n $this->logger->info('[Salesforce] Skip look up, Activity not linked to Lead, Contact or Account', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n $playbook = $this->getPlaybook($activity->getUser());\n if ($playbook !== null && $playbook->getActivityType() === Playbook::ACTIVITY_TYPE_TASK) {\n $this->logger->info('[Salesforce] Skip auto-sync for task-based playbook', [\n 'activityUuid' => $activity->getUuid(),\n 'playbookId' => $playbook->getId(),\n 'playbookType' => $playbook->getActivityType(),\n ]);\n\n return null;\n }\n\n try {\n $sfEvent = $this->fetchRelatedActivity($activity);\n if (empty($sfEvent)) {\n return null;\n }\n\n [$activityField, $activityType] = $this->resolveActivityTypeFromEvent($activity, $sfEvent);\n\n $this->logger->info('[Salesforce] Found related activity', [\n 'activityId' => $activity->getUuid(),\n 'sfEvent' => $sfEvent['Id'],\n 'activityFieldName' => $activityField,\n 'crmActivityType' => ($activityField !== null && isset($sfEvent[$activityField]))\n ? $sfEvent[$activityField]\n : null,\n 'activityType' => $activityType,\n ]);\n\n $userId = $this->findRelatedActivityUserId($activity, $sfEvent);\n\n if ($activity->getUserId() !== $userId) {\n $this->logger->info('[Salesforce] Updating meeting owner', [\n 'activityId' => $activity->getUuid(),\n 'oldUserId' => $activity->getUserId(),\n 'newUserId' => $userId,\n ]);\n }\n\n $this->updateSfEventDescription($activity, $sfEvent);\n\n $activity->update([\n 'user_id' => $userId,\n 'crm_provider_id' => $sfEvent['Id'],\n 'playbook_category_id' => $activityType->id ?? $activity->getCategory()?->getId(),\n ]);\n\n $this->logger->info('[Salesforce] Activity updated', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return $activity;\n } catch (\\Exception $exception) {\n \\Sentry::captureException($exception);\n\n throw $exception;\n }\n }\n\n /**\n * @param array<string, mixed> $sfEvent\n *\n * @return array{0: string|null, 1: mixed}\n */\n private function resolveActivityTypeFromEvent(Activity $activity, array $sfEvent): array\n {\n $activityField = $this->getActivityFieldName($activity);\n $activityType = null;\n\n if ($activityField !== null && ! empty($sfEvent[$activityField])) {\n $playbook = $this->getPlaybook($activity->getUser());\n $activityType = $this->getPlaybookCategory($playbook, strval($sfEvent[$activityField]));\n }\n\n return [$activityField, $activityType];\n }\n\n /**\n * @param array<string> $sfEvent\n */\n private function findRelatedActivityUserId(Activity $activity, array $sfEvent): int\n {\n $userId = $activity->getUserId();\n\n if (empty($sfEvent['OwnerId']) === false) {\n $profile = $this\n ->config\n ->profiles()\n ->where('crm_provider_id', $sfEvent['OwnerId'])\n ->get()\n ->filter(static function (Profile $profile) use ($activity): bool {\n if (! $activity->isTypeConference()) {\n return ! empty($profile->user) ? $profile->user->isStatusActive() : false;\n }\n\n $participants = $activity->getParticipants();\n\n return ! empty($profile->user)\n ? $profile->user->isStatusActive()\n && $profile->user->hasPermission(PermissionEnum::RECORD_MEETING)\n && $participants->contains('user_id', $profile->user_id)\n : false;\n })\n ->first();\n\n if ($profile) {\n $userId = $profile->user_id;\n }\n }\n\n return $userId;\n }\n\n /**\n * @param array<string, mixed> $sfEvent\n */\n private function updateSfEventDescription(Activity $activity, array $sfEvent): void\n {\n try {\n if (str_contains($sfEvent['Description'], $activity->id_string)) {\n return;\n }\n\n $payload = [\n 'Description' => $sfEvent['Description']\n . PHP_EOL\n . PHP_EOL\n . (new DecorateActivity())->generateDescription($activity),\n ];\n\n $this->logger->info('[Salesforce] Update record', [\n 'activityId' => $activity->getUuid(),\n 'sfEvent' => $sfEvent['Id'],\n 'payload' => $payload,\n ]);\n\n $payload = array_merge(\n $payload,\n $this->payloadBuilder->fetchCustomFieldData($activity, Field::OBJECT_EVENT)\n );\n\n $this->updateRecord('Event', $sfEvent['Id'], $payload);\n } catch (\\Exception) {\n $this->logger->error('[Salesforce] Failed to update record', [\n 'activityUuid' => $activity->getUuid(),\n 'sfEvent' => $sfEvent['Id'],\n ]);\n }\n }\n\n /**\n * Returns the most recently modified Event within time range (if any).\n *\n * @return array|null An Event record from Salesforce.\n */\n private function fetchRelatedEvent(Activity $activity): ?array\n {\n $ownerId = $this->profile?->crm_provider_id;\n if ($ownerId === null) {\n return [];\n }\n\n /** @var ?Carbon $from */\n /** @var ?Carbon $to */\n [$from, $to] = $this->getFromToDates($activity);\n\n try {\n $whoId = null;\n $hasWho = $activity->lead_id || $activity->contact_id;\n if ($hasWho) {\n $whoId = $activity->hasLead()\n ? $activity->getLead()->crm_provider_id\n : $activity->getContact()->crm_provider_id;\n }\n\n if ($hasWho === false && $activity->account_id === null) {\n return null;\n }\n\n $query = $this->buildFetchRelatedEventQuery($activity);\n\n $objects = $this->queryHandler->query($query, [\n 'ownerId' => $ownerId,\n 'whoId' => $whoId,\n 'whatId' => $activity->hasOpportunity() ? $activity->getOpportunity()->crm_provider_id : null,\n 'accountId' => $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null,\n 'from' => $from?->format('Y-m-d\\TH:i:s\\Z'),\n 'to' => $to?->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($objects as $object) {\n return $object;\n }\n } catch (NoResultsException $e) {\n return [];\n }\n\n return [];\n }\n\n private function getFromToDates(Activity $activity): array\n {\n $from = null;\n $to = null;\n\n /** @var ?CalendarEvent $calendarEvent */\n $calendarEvent = $activity->calendarEvent()->first();\n if ($calendarEvent !== null) {\n $from = $calendarEvent->getStartTime();\n $to = $calendarEvent->getEndTime();\n }\n\n // For non-calendar imported activities\n // Also double check if calendar event dates could be null?\n // If null use what we've got so far\n if ($from === null || $to === null) {\n $from = $activity->hasScheduledStartTime()\n ? $activity->getScheduledStartTime()\n : $activity->getActualStartTime();\n $to = $activity->hasScheduledEndTime()\n ? $activity->getScheduledEndTime()->addMinutes(15)\n : $activity->getActualEndTime();\n }\n\n return [$from, $to];\n }\n\n /**\n * Determines the appropriate activity field name for querying Salesforce events.\n *\n * This method follows a hierarchy to determine the field name:\n * 1. Uses the playbook's activity field if it exists and is in the profile's accessible fields\n * 2. Falls back to the default activity field if the profile has no event fields configured\n * 3. Returns null if no suitable field is found\n *\n * @param Activity $activity The activity to determine the field for\n *\n * @return string|null The field name to use in queries, or null if none is available\n */\n private function getActivityFieldName(Activity $activity): ?string\n {\n if ($this->profile === null) {\n $this->logger->warning('[Salesforce] Cannot determine activity field - profile not found', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n $profileEventFields = $this->profile->getFieldsAsArray('event');\n\n if (empty($profileEventFields)) {\n $defaultActivityField = $this->getDefaultActivityField(Field::OBJECT_EVENT);\n $defaultFieldName = $defaultActivityField?->getAttribute('crm_provider_id');\n // Profile not yet synced — fall back to the default activity field.\n // There is a small chance that the profile won't have Default Activity Type field access\n // in which case the query will fail.\n // This is however an edge case and should be reviewed for profile sync issues.\n Sentry::withScope(function (\\Sentry\\State\\Scope $scope) use ($defaultFieldName): void {\n $scope->setContext('details', [\n 'profileId' => $this->profile->id,\n 'defaultField' => $defaultFieldName,\n ]);\n Sentry::captureMessage(\n '[Salesforce] Profile event fields empty, falling back to default activity field.',\n \\Sentry\\Severity::warning()\n );\n });\n\n return $defaultFieldName;\n }\n\n $playbook = $this->getPlaybook($activity->getUser());\n\n if (! is_null($playbook) && ! is_null($playbook->getActivityField())) {\n $playbookFieldName = $playbook->getActivityField()->getAttribute('crm_provider_id');\n\n if (in_array($playbookFieldName, $profileEventFields, true)) {\n return $playbookFieldName;\n }\n\n $this->logger->warning('[Salesforce] Playbook activity field not found in profile fields', [\n 'activityId' => $activity->getUuid(),\n 'playbookField' => $playbookFieldName,\n 'profileId' => $this->profile->id,\n ]);\n }\n\n return null;\n }\n\n private function buildFetchRelatedEventQuery(Activity $activity): string\n {\n $hasWho = $activity->lead_id || $activity->contact_id;\n\n $activityFieldName = $this->getActivityFieldName($activity);\n $fields = array_filter(['Id', 'Description', 'OwnerId', $activityFieldName]);\n\n $ownerCondition = '(OwnerId = :ownerId OR CreatedById = :ownerId)';\n\n $query = '\n SELECT ' . implode(',', $fields) . '\n FROM Event\n WHERE ' . $ownerCondition . '\n AND IsArchived = false\n AND IsAllDayEvent = false\n AND StartDateTime >= :from\n AND EndDateTime <= :to\n AND (';\n\n $operator = '';\n if ($activity->account_id) {\n // This covers events tied to a related contact or opportunity too.\n $query .= 'AccountId = :accountId';\n\n $operator = ' OR ';\n }\n\n if ($hasWho) {\n $query .= $operator . 'WhoId = :whoId';\n\n // If we are also going to check on a specific opportunity, set that up.\n if ($activity->opportunity_id) {\n $query .= ' OR WhatId = :whatId';\n }\n }\n\n $query .= ') ORDER BY LastModifiedDate DESC';\n\n return $query;\n }\n\n public function fetchProspect(array $task): array\n {\n $lead = $account = $opportunity = $contact = $stage = $countryCode = null;\n $externalId = $task['WhoId'] ?? null;\n\n // Lead or Contact\n if ($externalId) {\n try {\n [$lead, $account, $opportunity, $contact, $stage, $countryCode] = $this->parseRecords($externalId);\n } catch (\\InvalidArgumentException $exception) {\n // Invalid object type.\n }\n }\n\n // If we happen to know the opportunity or account from the Task, figure that out.\n if (empty($task['WhatId']) === false) {\n // WhatId could be either Account ID or Opportunity ID.\n // If WhatId is Opportunity ID, get the opportunity and stage from the CRM.\n try {\n [, $account, $opportunity, , $stage, ] = $this->parseRecords($task['WhatId']);\n } catch (\\InvalidArgumentException $exception) {\n // Invalid object type.\n }\n }\n\n return [$lead, $account, $opportunity, $contact, $stage, $countryCode];\n }\n\n /**\n * Save activity transcription summary as note\n */\n public function saveTranscriptionSummaryAsNote(\n ActivityContract $activity,\n string $title,\n string $body,\n ?string $objectId,\n ?NoteObject $noteObject = null,\n ): ?string {\n return $this->saveNote($title, $body, (string) $objectId);\n }\n\n public function getObjectByFilterConditions(string $objectType, array $fields, array $filters): ?array\n {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $query = $queryBuilder->buildObjectSearchQuery($objectType, $fields, $filters);\n\n try {\n $objects = $this->queryHandler->query($query, $filters);\n if ($objects->count() === 1) {\n return $objects->current();\n }\n } catch (\\Exception $e) {\n $this->logger->info('[Salesforce] Failed to execute query', [\n 'query' => $query,\n 'error' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n private function getCustomProfileRules(TeamRepository $teamRepository): array\n {\n $teamSettings = $teamRepository->getTeamSetting($this->team, 'custom_profile_validation');\n\n if ($teamSettings instanceof TeamSettings && $teamSettings->getValueType() === 'array') {\n $customRules = json_decode($teamSettings->getValue(), true);\n if (is_array($customRules)) {\n return $customRules;\n }\n }\n\n return [];\n }\n\n private function customProfileValidation(array $crmUser, array $customRules): bool\n {\n foreach ($customRules as $customRule) {\n if ($crmUser[$customRule['field']] !== $customRule['value']) {\n return false;\n }\n }\n\n return true;\n }\n\n /**\n * When syncing Contact / Lead / Account / Opportunity / Stage crm entities,\n * validate and restore locally trashed objects,\n * before updating them. Objects are identified by CrmProviderId\n */\n private function restoreAnyTrashedEntity(HasMany $targetEntity, string $crmProviderId): void\n {\n $recordExists = $targetEntity->withTrashed()->where(['crm_provider_id' => $crmProviderId])->first();\n if ($recordExists && $recordExists->trashed()) {\n $recordExists->restore();\n }\n }\n\n #[\\Override] public function supportsNotes(): bool\n {\n return true;\n }\n\n private function getOwnerProfile(?string $ownerId): ?Profile\n {\n if ($ownerId === null) {\n return null;\n }\n\n return $this->config->profiles()\n ->where('crm_provider_id', $ownerId)\n ->first();\n }\n}","depth":2,"bounds":{"left":0.25531915,"top":0.42697525,"width":0.29089096,"height":0.57302475},"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Salesforce;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse Illuminate\\Database\\Eloquent\\Relations\\HasMany;\nuse Illuminate\\Events\\Dispatcher;\nuse Illuminate\\Support\\Facades\\Cache;\nuse Illuminate\\Support\\Facades\\Log;\nuse Illuminate\\Support\\Str;\nuse Jiminny\\Component\\Country\\CountriesMap;\nuse Jiminny\\Contracts\\Acl\\PermissionEnum;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\FetchRelatedActivityInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\ImportsBusinessProcessesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\SalesforceBatchSyncInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\SalesforceInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteNoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SearchTaskInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SendSummaryToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SupportsObjectTypeParseInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmProfileRecordTypesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Enums\\CrmObject;\nuse Jiminny\\Events\\Activities\\Crm\\LeadConverted;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpBadRequestException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Exceptions\\NoResultsException;\nuse Jiminny\\Exceptions\\ServiceUnavailableException;\nuse Jiminny\\Jobs\\Crm\\NoteObject;\nuse Jiminny\\Jobs\\Crm\\MatchActivitiesToNewOpportunity;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Calendar\\CalendarEvent;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Contracts\\ActivityContract;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Configuration;\nuse Jiminny\\Models\\Crm\\ContactRole;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Crm\\RecordType;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\TeamSettings;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\ContactRoleRepository;\nuse Jiminny\\Repositories\\Crm\\FieldRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Repositories\\Crm\\RecordTypeFieldValuesRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\Helpers\\ArrayIterator;\nuse Jiminny\\Services\\Crm\\MatchDomainByEmailInterface;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Services\\Crm\\ResolveCompanyNameByEmailTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\Fields\\FieldHelper;\nuse Jiminny\\Services\\Crm\\Salesforce\\Fields\\FieldTypeConverter;\nuse Jiminny\\Services\\Crm\\Salesforce\\Fields\\ValueNormalizer;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\FollowupActivityTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\LogActivityTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\RecordManipulationsTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\SyncFieldsTrait;\nuse Jiminny\\Utils\\CurrencyFormatter;\nuse Jiminny\\Utils\\StringUtil;\nuse Ramsey\\Uuid\\Uuid;\nuse Sentry\\Laravel\\Facade as Sentry;\n\nclass Service extends BaseService implements\n SalesforceInterface,\n SalesforceBatchSyncInterface,\n SyncCrmEntitiesInterface,\n SyncCrmProfileRecordTypesInterface,\n ImportsBusinessProcessesInterface,\n RemoteEntityManipulationInterface,\n FetchRelatedActivityInterface,\n SendSummaryToCrmInterface,\n MatchDomainByEmailInterface,\n SearchTaskInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n SupportsObjectTypeParseInterface,\n RemoteNoteEntityManipulationInterface,\n VerifyTaskExistsInterface\n{\n use ResolveCompanyNameByEmailTrait;\n use SyncFieldsTrait;\n use DeleteObjectsTrait;\n use RecordManipulationsTrait;\n use ServiceTraits\\BatchSyncTrait;\n use FollowupActivityTrait;\n use LogActivityTrait;\n\n /**\n * Note Body Limit for the Old Note-Taking Tool\n *\n * @var int\n */\n private const int CLASSIC_NOTE_MAX_LENGTH = 32000;\n\n /**\n * Note Content Limit for the New Notes\n *\n * @var int\n */\n private const int ENHANCED_NOTE_MAX_LENGTH = 50000000;\n\n private const string INSTALLED_PACKAGE_ID = '033Tw0000007bKbIAI';\n\n private const int CACHE_TTL = 600;\n\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day - 86400\n\n /**\n * @var Client\n */\n protected $client;\n\n protected PayloadBuilder $payloadBuilder;\n protected QueryHandler $queryHandler;\n\n private OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n\n public function __construct(\n Client $client,\n PayloadBuilder $payloadBuilder,\n protected Dispatcher $eventDispatcher,\n private readonly CountriesMap $countriesMap,\n private readonly ProspectPhotoPathService $prospectPhotoPathService,\n ) {\n parent::__construct();\n\n $this->client = $client;\n $this->payloadBuilder = $payloadBuilder;\n $this->queryHandler = app(QueryHandler::class, [\n 'client' => $this->client,\n 'logger' => $this->logger,\n ]);\n $this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n }\n\n public function getDisplayName(): string\n {\n return 'Salesforce';\n }\n\n public function getJobDelay(): int\n {\n return 1;\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n return $user->getSocialAccount(SocialAccount::PROVIDER_SALESFORCE);\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($activity, $crmProviderId) {\n $playbook = $this->getPlaybookFromActivity($activity);\n\n if ($playbook === null) {\n $this->logger->warning('[Salesforce] Cannot verify task - no playbook found', [\n 'activity' => $activity->getId(),\n 'crm_provider_id' => $crmProviderId,\n ]);\n\n return false;\n }\n\n $objectType = $playbook->getActivityType() === Playbook::ACTIVITY_TYPE_EVENT ? 'Event' : 'Task';\n\n try {\n $record = $this->getRecord($objectType, $crmProviderId, ['Id', 'IsDeleted']);\n\n return ! empty($record) && ($record['IsDeleted'] ?? false) === false;\n } catch (HttpNotFoundException|HttpBadRequestException) {\n $this->logger->info('[Salesforce] Activity record not found during verification', [\n 'activity' => $activity->getId(),\n 'object_type' => $objectType,\n 'crm_provider_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n });\n }\n\n public function query(string $queryToRun, array $parameters = []): QueryIterator\n {\n // Due to poorly designed external calls, this method cannot be entirely removed\n return $this->queryHandler->query($queryToRun, $parameters);\n }\n\n /*=========== Organization Information ===============*/\n\n /**\n * Get a list of all the API Versions for the instance.\n *\n * @throws CrmException\n *\n * @return mixed\n *\n */\n public function getApiVersions()\n {\n $url = $this->config->crm_base_url . '/services/data';\n\n $response = $this->client->get($url);\n\n return json_decode($response->getBody(), true);\n }\n\n /**\n * Gets the valid recordTypes for a given Salesforce Object via the describe API.\n */\n private function getRecordTypes(string $crmObject): array\n {\n $url = $this->client->getObjectsUrl() . $crmObject . '/describe';\n\n $response = $this->client->get($url);\n $jsonResponse = json_decode($response->getBody(), true);\n\n $fields = [];\n foreach ($jsonResponse['recordTypeInfos'] as $row) {\n $fields[] = ['recordTypeId' => $row['recordTypeId'], 'default' => $row['defaultRecordTypeMapping']];\n }\n\n return $fields;\n }\n\n /**\n * Convert raw field data into a format compatible with CRM APIs.\n */\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return ValueNormalizer::normalize($fieldType, $fieldValue, $internal);\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n $defaultFields = ($activityType === Playbook::ACTIVITY_TYPE_TASK)\n ? FieldDefinitions::defaultTaskFields()\n : FieldDefinitions::defaultEventFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n // Setup the activity field as the default Type.\n /** @var Field $activityField */\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'Type',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK, Playbook::ACTIVITY_TYPE_EVENT];\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n\n $fieldFilter = ($activityType === Playbook::ACTIVITY_TYPE_TASK)\n ? FieldDefinitions::taskFollowupFieldsFilter()\n : FieldDefinitions::eventFollowupFieldsFilter();\n\n foreach ($fieldFilter as $eachFilter) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $eachFilter);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n /**\n * This one is now called only when ImportActivityTypes is triggered or SyncFieldMetadata executed manually\n * Regular sync now uses SharedSyncFieldsTrait -> syncSingleObjectType\n * Needs to be replaced later on\n */\n public function syncField(Field $field): void\n {\n try {\n if (FieldHelper::isCustomField($field)) {\n $query = '\n SELECT\n Id, Metadata, TableEnumOrId\n FROM\n CustomField\n WHERE\n DeveloperName = :fieldName\n AND\n TableEnumOrId = :fieldType\n AND\n NamespacePrefix = :namespacePrefix';\n\n // We need to constrain the field lookup to the object, in case it's used in multiple places.\n $objectType = \\in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true)\n ? 'activity'\n : $field->object_type;\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'fieldName' => substr($field->crm_provider_id, 0, -\\strlen('__c')),\n 'fieldType' => ucfirst($objectType),\n\n // This is used to ensure we only consider the field within the org, not installed packages.\n 'namespacePrefix' => 'null',\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n // Sync field metadata.\n $metadata = $sfField['Metadata'];\n\n $field->description = mb_strimwidth($metadata['description'] ?? '', 0, 191);\n $field->label = mb_strimwidth($metadata['label'] ?? '', 0, Field::LABEL_MAX_LENGTH);\n $field->type = $this->convertFieldType($metadata['type'], $field->getEntityName());\n $field->is_mandatory = ($metadata['required'] === true);\n $field->length = $metadata['length'];\n $field->default_value = mb_strimwidth(trim($metadata['defaultValue'] ?? '', '\"'), 0, 191);\n $field->save();\n } else {\n $query = '\n SELECT\n Id, DataType, DeveloperName, Label, Length, Description\n FROM\n FieldDefinition\n WHERE\n DurableId = :entityName';\n\n $entityName = $field->getEntityName();\n $sfFields = $this->queryHandler->metadata($query, [\n 'entityName' => $entityName,\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n $convertedType = $this->convertFieldType($sfField['DataType'], $entityName);\n $label = mb_strimwidth($sfField['Label'], 0, Field::LABEL_MAX_LENGTH);\n\n if ($field->isBusinessType()) {\n $label = 'Opportunity Type';\n }\n\n $field->description = mb_strimwidth($sfField['Description'], 0, Field::DESCRIPTION_MAX_LENGTH);\n $field->label = $label;\n $field->type = $convertedType;\n $field->length = $sfField['Length'];\n $field->save();\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n private function convertFieldType(string $from, ?string $entityName = null): string\n {\n $converter = new FieldTypeConverter();\n\n return $converter->convert($from, $entityName);\n }\n\n /**\n * @inheritdoc\n */\n public function importPicklistValues(Field $field): array\n {\n $values = [];\n $fieldValues = [];\n\n try {\n if (FieldHelper::isCustomField($field)) {\n $query = '\n SELECT\n Id, Metadata, TableEnumOrId\n FROM\n CustomField\n WHERE\n DeveloperName = :fieldName\n AND\n TableEnumOrId = :fieldType\n AND\n NamespacePrefix = :namespacePrefix';\n\n // We need to constrain the field lookup to the object, in case it's used in multiple places.\n $objectType = \\in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true) ?\n 'activity' : $field->object_type;\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'fieldName' => substr($field->crm_provider_id, 0, -\\strlen('__c')),\n 'fieldType' => ucfirst($objectType),\n // This is used to ensure we only consider the field within the org, not installed packages.\n 'namespacePrefix' => 'null',\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n $valueSet = $sfField['Metadata']['valueSet'];\n\n if ($valueSet['valueSetName'] === null) {\n // Local picklist values can be obtained easily.\n $picklistValues = $valueSet['valueSetDefinition']['value'];\n } else {\n // But for some fields, we just get the Global Value Picklist pointer so need to do more work.\n $picklistValues = $this->importGlobalValuePicklistValues($valueSet['valueSetName']);\n }\n\n // Import all active values.\n foreach ($picklistValues as $i => $sfFieldValue) {\n // Setup default value.\n if ($sfFieldValue['default']) {\n $field->update(['default_value' => $sfFieldValue['valueName']]);\n }\n\n // This comes through as null if active (lol).\n if ($sfFieldValue['isActive'] !== false) {\n $values[] = [\n 'value' => $sfFieldValue['valueName'],\n 'label' => $sfFieldValue['valueName'],\n 'sequence' => $i,\n 'is_default' => $sfFieldValue['default'],\n ];\n }\n }\n } else {\n $objectFields = $this->getObjectFields($field->object_type);\n $fieldId = $field->crm_provider_id;\n\n // Only work with our field of interest.\n $objectField = array_filter($objectFields, function ($item) use ($fieldId) {\n return $item['name'] === $fieldId;\n });\n\n $objectField = array_shift($objectField);\n if (empty($objectField['picklistValues']) === false) {\n foreach ($objectField['picklistValues'] as $i => $sfFieldValue) {\n // Skip inactive values.\n if ($sfFieldValue['active'] === false) {\n continue;\n }\n\n // Setup default value.\n if ($sfFieldValue['defaultValue']) {\n $field->update(['default_value' => $sfFieldValue['value']]);\n }\n\n $values[] = [\n 'value' => $sfFieldValue['value'],\n 'label' => $sfFieldValue['label'],\n 'sequence' => $i,\n 'is_default' => $sfFieldValue['defaultValue'],\n ];\n }\n }\n }\n\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n $value['value'] = substr($value['value'] ?? '', 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n if (($key = array_search($value['value'], $fieldsToPurge, true)) !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n // Get IDs of the values to be deleted\n $valuesToDelete = $field->values()->whereIn('value', $fieldsToPurge);\n $valuesToDeleteIds = $valuesToDelete->pluck('id');\n if (! $valuesToDeleteIds->isEmpty()) {\n $recordTypeFieldValuesRepository = app(RecordTypeFieldValuesRepository::class);\n $recordTypeFieldValuesRepository->deleteForCrmFieldValueIds($valuesToDeleteIds->toArray());\n\n // Now safely delete from crm_field_values\n $valuesToDelete->delete();\n }\n\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n return $fieldValues;\n }\n\n /**\n * Gets values from Global Value Picklists.\n */\n private function importGlobalValuePicklistValues(string $picklistName): array\n {\n $query = '\n SELECT\n Metadata\n FROM\n GlobalValueSet\n WHERE\n DeveloperName = :picklistName\n LIMIT 1';\n\n try {\n $sfValues = $this->queryHandler->metadata($query, [\n 'picklistName' => $picklistName,\n ]);\n\n // There is always 1 result at this point.\n $sfValue = $sfValues->current();\n\n return $sfValue['Metadata']['customValue'];\n } catch (NoResultsException $noResultsException) {\n // Nothing returned.\n\n return [];\n }\n }\n\n /**\n * @inheritdoc\n */\n public function syncProfileRecordTypes(): void\n {\n $objectTypes = [\n 'lead',\n 'account',\n 'contact',\n 'opportunity',\n 'task',\n 'event',\n ];\n\n foreach ($objectTypes as $objectType) {\n try {\n $crmRecordTypes = $this->getRecordTypes(ucfirst($objectType));\n\n foreach ($crmRecordTypes as $crmRecordType) {\n // If the record type is default and not the Master type, set this.\n if ($crmRecordType['default'] && $crmRecordType['recordTypeId'] !== '012000000000000AAA') {\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $crmRecordType['recordTypeId'])\n ->first();\n\n if ($recordType) {\n $this->profile->{$objectType . '_record_type_id'} = $recordType->id;\n }\n }\n }\n } catch (HttpNotFoundException $exception) {\n Log::error('No access to ' . $objectType . ' object, skipping...');\n\n // XXX: should we log this fact somewhere?\n continue;\n }\n }\n\n if ($this->profile->isDirty()) {\n $this->profile->save();\n }\n }\n\n /**\n * Gets business processes.\n */\n public function importBusinessProcesses(): void\n {\n $query = '\n SELECT\n Id, IsActive, Name, TableEnumOrId\n FROM\n BusinessProcess\n WHERE\n TableEnumOrId IN (\\'Lead\\',\\'Opportunity\\')';\n\n try {\n $sfProcesses = $this->queryHandler->query($query);\n\n // Upsert all processes for the team.\n foreach ($sfProcesses as $sfProcess) {\n /** @var BusinessProcess $businessProcess */\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $sfProcess['Id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => $sfProcess['Name'],\n 'type' => $sfProcess['TableEnumOrId'] === 'Lead' ? 'lead' : 'opportunity',\n 'is_selectable' => $sfProcess['IsActive'],\n ]);\n\n $this->importBusinessProcessStages($businessProcess);\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n /**\n * Gets business process stages.\n */\n private function importBusinessProcessStages(BusinessProcess $businessProcess): void\n {\n $query = '\n SELECT\n Metadata\n FROM\n BusinessProcess\n WHERE\n Id = :processId';\n\n try {\n $stages = [];\n $sfProcessStages = $this->queryHandler->metadata($query, [\n 'processId' => $businessProcess->crm_provider_id,\n ]);\n\n // There is always 1 result at this point.\n $sfProcessStage = $sfProcessStages->current();\n\n // Upsert all processes for the team.\n foreach ($sfProcessStage['Metadata']['values'] as $sfProcessStage) {\n $sanitizedName = urldecode($sfProcessStage['valueName']); // Must decode: \"%2C\" becomes \",\" etc.\n\n $stage = $businessProcess->crm->stages()\n // This MUST match on label because this API doesn't use API Name.\n ->where('label', $sanitizedName)\n ->where('type', $businessProcess->type)\n ->where('is_selectable', 1)\n ->first();\n\n if ($stage) {\n $stages[] = $stage->id;\n }\n }\n\n $businessProcess->stages()->sync($stages);\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n /**\n * Gets record types.\n */\n public function importRecordTypes(): void\n {\n $query = '\n SELECT\n Id, IsActive, Name, BusinessProcessId, SobjectType\n FROM\n RecordType';\n\n try {\n $sfRecordTypes = $this->queryHandler->query($query);\n\n // Upsert all record types for the process.\n foreach ($sfRecordTypes as $sfRecordType) {\n $businessProcess = null;\n if ($sfRecordType['BusinessProcessId']) {\n $businessProcess = $this->config->businessProcesses()\n ->where('crm_provider_id', $sfRecordType['BusinessProcessId'])\n ->first();\n }\n\n /** @var RecordType $recordType */\n $recordType = $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $sfRecordType['Id'],\n ], [\n 'team_id' => $this->team->id,\n 'type' => mb_strtolower($sfRecordType['SobjectType']),\n 'name' => $sfRecordType['Name'],\n 'is_selectable' => $sfRecordType['IsActive'],\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n $this->importRecordTypeFieldValues($recordType);\n }\n } catch (NoResultsException $noResultsException) {\n // Do nothing.\n }\n }\n\n /**\n * Import record type - field value mappings. This only works for standard fields.\n */\n private function importRecordTypeFieldValues(RecordType $recordType): void\n {\n try {\n $query = '\n SELECT\n Metadata\n FROM\n RecordType\n WHERE\n Id = :recordTypeId';\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'recordTypeId' => $recordType->crm_provider_id,\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n // Sync field metadata.\n $picklists = $sfField['Metadata']['picklistValues'];\n\n foreach ($picklists as $picklist) {\n $field = $this->config->fields()->where([\n 'type' => Field::TYPE_PICKLIST,\n 'object_type' => $recordType->type,\n 'crm_provider_id' => $picklist['picklist'],\n ])->first();\n\n if ($field) {\n $fieldValues = [];\n\n foreach ($picklist['values'] as $value) {\n // Must decode: \"%2C\" becomes \",\" etc.\n $fieldValue = $field->values()\n ->where('value', urldecode($value['valueName']))\n ->first();\n\n if ($fieldValue) {\n $fieldValues[] = $fieldValue->id;\n }\n }\n\n $recordType->fieldValues()->sync($fieldValues);\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n /**\n * @inheritdoc\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $params = [];\n $missingStage = null;\n if ($types === null) {\n $types = [Stage::TYPE_LEAD, Stage::TYPE_OPPORTUNITY];\n }\n\n foreach ($types as $type) {\n if ($type === Stage::TYPE_LEAD) {\n $query = '\n SELECT\n Id, ApiName, MasterLabel, SortOrder\n FROM\n LeadStatus';\n } else {\n $query = '\n SELECT\n Id, ApiName, MasterLabel, IsActive, SortOrder, DefaultProbability\n FROM\n OpportunityStage';\n }\n\n if ($missingStageName) {\n $escapedStageName = ValueNormalizer::replaceQueryWithStringLiterals($missingStageName);\n\n $query .= ' WHERE ApiName = :stageName';\n\n $params = [\n 'stageName' => $escapedStageName,\n ];\n }\n\n try {\n $sfStages = $this->queryHandler->query($query, $params);\n } catch (NoResultsException $exception) {\n $sfStages = [];\n }\n\n $missingStage = null;\n\n // Upsert all stages for the team.\n foreach ($sfStages as $sfStage) {\n $selectable = true;\n if (array_key_exists('IsActive', $sfStage)) {\n $selectable = $sfStage['IsActive'];\n }\n\n $this->restoreAnyTrashedEntity($this->config->stages(), $sfStage['Id']);\n\n $stage = $this->config->stages()->updateOrCreate([\n 'crm_provider_id' => $sfStage['Id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($sfStage['ApiName'], 0, 50),\n 'label' => mb_strimwidth($sfStage['MasterLabel'], 0, 191),\n 'type' => $type,\n 'sequence' => $sfStage['SortOrder'] ?? 0,\n 'is_selectable' => $selectable,\n 'probability' => $sfStage['DefaultProbability'] ?? null,\n ]);\n\n if ($missingStageName && $missingStageName === $sfStage['ApiName']) {\n $missingStage = $stage;\n }\n }\n\n if ($missingStageName && $missingStage === null) {\n // If they requested a stage that still doesn't exist, it must be inactive so lazy create it.\n $missingStage = $this->config->stages()->create([\n 'crm_provider_id' => Uuid::uuid4(),\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($missingStageName, 0, 50),\n 'label' => mb_strimwidth($missingStageName, 0, 191),\n 'type' => $type,\n 'sequence' => 0,\n 'is_selectable' => 0,\n ]);\n }\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int\n {\n $syncCount = 0;\n $fields = $this->getAllFieldsAsArray('lead');\n if (\\in_array('Id', $fields, true) === false) {\n return $syncCount;\n }\n\n $query = '\n SELECT ' . rtrim(implode(',', $fields), ',') . '\n FROM Lead\n WHERE LastModifiedDate > :since\n ORDER BY LastModifiedDate ASC';\n\n try {\n $sfLeads = $this->queryHandler->query($query, [\n 'since' => $since->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($sfLeads as $sfLead) {\n // Only sync if previously imported.\n if ($this->hasLead($sfLead['Id'])) {\n $this->importLead($sfLead);\n $syncCount++;\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::LEAD);\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLead(string $crmId): ?Lead\n {\n $fields = $this->getAllFieldsAsArray('lead');\n\n $sfLead = $this->getRecord('Lead', $crmId, $fields);\n\n return $this->importLead($sfLead);\n }\n\n private function importLead($crmData): ?Lead\n {\n /** @var ?Stage $stage */\n $stage = null;\n if (isset($crmData['Status'])) {\n // Get the current stage.\n $stage = $this->config\n ->stages()\n ->where('name', $crmData['Status'])\n ->where('type', Stage::TYPE_LEAD)\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_LEAD], $crmData['Status']);\n }\n }\n\n // If we have no way of importing this, just return null :(\n if ($stage === null) {\n return null;\n }\n\n $countryCode = $crmData['CountryCode'] ?? null;\n\n // Salesforce allows custom \"countries\" to be created. Disregard these.\n if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {\n $countryCode = null;\n }\n\n // If we have no country code, try to parse it from the country name.\n if ($countryCode === null && empty($crmData['Country']) !== false) {\n $countryCode = $this->convertCountryNameToCode($crmData['Country']);\n }\n\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($crmData['Phone'] ?? '', 0, 25);\n $parsedNumber = parsePhoneNumber($countryCode, $number);\n\n $mobilePhone = null;\n if (empty($crmData['MobilePhone']) === false) {\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($crmData['MobilePhone'], 0, 25);\n $mobilePhone = phone_e164($countryCode, $number);\n }\n\n $convertedDate = null;\n $convertedAccount = null;\n $convertedOpportunity = null;\n $convertedContact = null;\n\n if ($crmData['IsConverted'] == 'true') {\n $convertedDate = $crmData['ConvertedDate'];\n\n if (empty($crmData['ConvertedAccountId']) === false) {\n $convertedAccount = $this->config\n ->accounts()\n ->where('crm_provider_id', $crmData['ConvertedAccountId'])\n ->first();\n\n if ($convertedAccount === null) {\n try {\n $convertedAccount = $this->syncAccount($crmData['ConvertedAccountId']);\n } catch (HttpNotFoundException $exception) {\n // Probably the user has no permissions to access the converted data.\n }\n }\n }\n\n if (empty($crmData['ConvertedOpportunityId']) === false) {\n $convertedOpportunity = $this->config\n ->opportunities()\n ->where('crm_provider_id', $crmData['ConvertedOpportunityId'])\n ->first();\n\n if ($convertedOpportunity === null) {\n try {\n $convertedOpportunity = $this->syncOpportunity($crmData['ConvertedOpportunityId']);\n } catch (HttpNotFoundException $exception) {\n // Probably the user has no permissions to access the converted data.\n }\n }\n }\n\n if (empty($crmData['ConvertedContactId']) === false) {\n $convertedContact = $this->team\n ->crm\n ->contacts()\n ->where('crm_provider_id', $crmData['ConvertedContactId'])\n ->first();\n\n if ($convertedContact === null) {\n try {\n $convertedContact = $this->syncContact($crmData['ConvertedContactId']);\n } catch (HttpNotFoundException $exception) {\n // Probably the user has no permissions to access the converted data.\n }\n }\n }\n }\n\n if (empty($crmData['Company'])) {\n $company = 'Unknown';\n } else {\n $company = mb_strimwidth($crmData['Company'], 0, 191);\n }\n\n $domain = null;\n if (empty($crmData['Website']) === false) {\n $domain = mb_strimwidth($crmData['Website'], 0, 191);\n $domain = StringUtil::resolveDomain($domain);\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $data = [\n 'team_id' => $this->team->id,\n 'user_id' => $profile?->user_id,\n 'owner_id' => $crmData['OwnerId'] ?? '',\n 'company' => $company,\n 'domain' => $domain,\n 'name' => $crmData['Name'] ? mb_strimwidth($crmData['Name'], 0, 191) : '',\n 'title' => $crmData['Title'] ? mb_strimwidth($crmData['Title'], 0, 128) : null,\n 'email' => $crmData['Email'] ? mb_strimwidth($crmData['Email'], 0, 80) : null,\n 'phone' => $parsedNumber['phone'],\n 'ext' => $parsedNumber['ext'] ?? null,\n 'mobile_phone' => $mobilePhone,\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['Id'],\n modelType: Lead::class,\n fileName: $crmData['Id'],\n avatarText: $crmData['Name']\n ),\n 'stage_id' => $stage->id,\n 'record_type_id' => null,\n 'converted_at' => $convertedDate,\n 'converted_account_id' => $convertedAccount->id ?? null,\n 'converted_opportunity_id' => $convertedOpportunity->id ?? null,\n 'converted_contact_id' => $convertedContact->id ?? null,\n 'country_code' => $countryCode,\n 'remotely_created_at' => $createdDate,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->leads(), $crmData['Id']);\n\n /** @var Lead $lead */\n $lead = $this->config->leads()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n if ($lead->wasChanged('converted_at') && $lead->getConvertedAt() !== null) {\n $this->eventDispatcher->dispatch(new LeadConverted($lead));\n }\n\n $this->handleObjectDeletion($lead, $crmData);\n\n return $lead;\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccounts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n $fields = $this->getAllFieldsAsArray('account');\n\n if (\\in_array('Id', $fields, true) === false) {\n return $syncCount;\n }\n\n $query = '\n SELECT ' . rtrim(implode(',', $fields), ',') . '\n FROM Account\n WHERE LastModifiedDate > :since\n ORDER BY LastModifiedDate ASC';\n\n try {\n $sfAccounts = $this->queryHandler->query($query, [\n 'since' => $since->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($sfAccounts as $sfAccount) {\n // Only sync if previously imported.\n if ($this->hasAccount($sfAccount['Id'])) {\n $this->importAccount($sfAccount);\n $syncCount++;\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::ACCOUNT);\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccount(string $crmId): ?Account\n {\n $fields = $this->getAllFieldsAsArray('account');\n if (! in_array('Id', $fields, true)) {\n $this->logger->info('[Salesforce] Sync account cancelled. Fields are not available.', [\n 'crmId' => $crmId,\n 'userId' => $this->profile->getUserId(),\n ]);\n\n return null;\n }\n\n $sfAccount = $this->getRecord('Account', $crmId, $fields);\n\n return $this->importAccount($sfAccount);\n }\n\n private function importAccount($crmData): Account\n {\n $countryCode = $crmData['BillingCountryCode'] ?? $crmData['ShippingCountryCode'] ?? null;\n\n // Salesforce allows custom \"countries\" to be created. Disregard these.\n if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {\n $countryCode = null;\n }\n\n // If we have no country code, try to parse it from the country names.\n if ($countryCode === null && empty($crmData['BillingCountry']) === false) {\n $countryCode = $this->convertCountryNameToCode($crmData['BillingCountry']);\n }\n\n if ($countryCode === null && empty($crmData['ShippingCountry']) === false) {\n $countryCode = $this->convertCountryNameToCode($crmData['ShippingCountry']);\n }\n\n if (empty($crmData['Phone']) === false) {\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($crmData['Phone'], 0, 25);\n $parsedNumber = parsePhoneNumber($countryCode, $number);\n } else {\n $parsedNumber = [];\n }\n\n $industry = null;\n if (empty($crmData['Industry']) === false) {\n $industry = mb_strimwidth($crmData['Industry'], 0, 40);\n }\n\n $domain = null;\n if (empty($crmData['Website']) === false) {\n $domain = mb_strimwidth($crmData['Website'], 0, 191);\n $domain = StringUtil::resolveDomain($domain);\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $data = [\n 'team_id' => $this->team->id,\n 'user_id' => $profile?->user_id,\n 'owner_id' => $crmData['OwnerId'],\n 'name' => mb_strimwidth($crmData['Name'], 0, 191),\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['Id'],\n modelType: Account::class,\n fileName: $crmData['Id'],\n avatarText: $crmData['Name']\n ),\n 'industry' => $industry,\n 'domain' => $domain,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'ext' => $parsedNumber['ext'] ?? null,\n 'country_code' => $countryCode,\n 'remotely_created_at' => $createdDate,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->accounts(), $crmData['Id']);\n\n /** @var Account $account */\n $account = $this->config->accounts()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n $this->handleObjectDeletion($account, $crmData);\n\n return $account;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOpportunities(array $parameters, ?string $strategy = null): int\n {\n $strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);\n\n $syncCount = 0;\n $logParams = $parameters;\n $parameters['profile'] = $this->profile;\n $logParams['user'] = $this->profile->getUserId();\n\n if (count($strategies) > 1) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Multiple sync strategies used', [\n 'teamId' => $this->team->getUuid(),\n 'params' => $logParams,\n 'strategies_count' => count($strategies),\n ]);\n }\n\n foreach ($strategies as $syncStrategy) {\n $name = $syncStrategy->getStrategyName();\n\n try {\n $sfOpportunities = $syncStrategy->fetchOpportunities($parameters);\n $totalRecords = $sfOpportunities->count();\n\n foreach ($sfOpportunities as $sfOpportunity) {\n $this->importOpportunity($sfOpportunity);\n $syncCount++;\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n $this->logger->warning('[' . $this->getDisplayName() . '] No opportunities found', [\n 'teamId' => $this->team->getUuid(),\n 'name' => $name,\n 'params' => $logParams,\n 'reason' => $noResultsException->getMessage(),\n ]);\n } catch (CrmException $crmException) {\n // Nothing to sync.\n $this->logger->warning('[' . $this->getDisplayName() . '] Opportunity sync failed', [\n 'teamId' => $this->team->getUuid(),\n 'name' => $name,\n 'params' => $logParams,\n 'reason' => $crmException->getMessage(),\n ]);\n }\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::OPPORTUNITY, ['params' => $logParams]);\n\n // debug to see how if count of opportunities reaches 1000\n if ($syncCount >= 1000) {\n $this->logger->info(\n '[' . $this->getDisplayName() . '] Sync Opportunities - count warning',\n [\n 'team_id' => $this->team->getId(),\n 'params' => $logParams,\n 'count' => $syncCount,\n 'strategies_count' => count($strategies),\n 'total_records' => $totalRecords ?? null,\n ]\n );\n }\n\n return $syncCount;\n }\n\n\n /**\n * @inheritdoc\n */\n public function syncOpportunity(string $crmId): ?Opportunity\n {\n $strategy = $this->opportunitySyncStrategyResolver->resolve(\n $this->config,\n OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY\n );\n\n $parameters = [\n 'profile' => $this->profile,\n 'crm_id' => $crmId,\n ];\n\n try {\n $sfOpportunity = $strategy->fetchOpportunities($parameters);\n } catch (HttpNotFoundException $e) {\n $this->logger->info('[' . $this->getDisplayName() . '] Opportunity not found', [\n 'teamId' => $this->team->id_string,\n 'crmId' => $crmId,\n ]);\n\n return null;\n } catch (CrmException $crmException) {\n $this->logger->info('[' . $this->getDisplayName() . '] Opportunity sync failed', [\n 'teamId' => $this->team->id_string,\n 'crmId' => $crmId,\n 'exception' => $crmException->getMessage(),\n ]);\n\n return null;\n }\n\n if ($sfOpportunity instanceof ArrayIterator) {\n return $this->importOpportunity($sfOpportunity->getItems());\n }\n\n return $this->importOpportunity($sfOpportunity);\n }\n\n /**\n * @throws HttpNotFoundException\n */\n private function importOpportunity($crmData): ?Opportunity\n {\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $account = null;\n if (empty($crmData['AccountId']) === false) {\n /** @var ?Account $account */\n $account = $this->config->accounts()\n ->where('crm_provider_id', (string) $crmData['AccountId'])\n ->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmData['AccountId']);\n }\n }\n\n $userId = $profile?->getUserId() ?? $account?->getUserId();\n if ($userId === null) {\n $this->logger->error('[Salesforce] | Skip import, no user_id found', [\n 'id' => $crmData['Id'],\n ]);\n\n return null;\n }\n\n /** @var ?Stage $stage */\n $stage = null;\n if (isset($crmData['StageName'])) {\n $stage = $this->config\n ->stages()\n ->where('name', $crmData['StageName'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->orderBy('is_selectable', 'DESC')\n ->orderBy('id')\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $crmData['StageName']);\n }\n }\n\n $recordType = null;\n if (empty($crmData['RecordTypeId']) === false) {\n /** @var ?RecordType $recordType */\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $crmData['RecordTypeId'])\n ->first();\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $closeDate = null;\n if (empty($crmData['CloseDate']) === false) {\n $closeDate = Carbon::parse($crmData['CloseDate'])->format('Y-m-d');\n }\n\n $valueFieldName = 'Amount';\n if ($this->config->opportunity_value_field_id) {\n $valueFieldName = $this->config->opportunityValueField->crm_provider_id;\n }\n\n $data = [\n 'team_id' => $this->team->id,\n 'account_id' => $account->id ?? null,\n 'user_id' => $userId,\n 'owner_id' => $crmData['OwnerId'] ?? null,\n 'name' => mb_strimwidth($crmData['Name'] ?? '', 0, 128),\n 'value' => $crmData[$valueFieldName],\n 'currency_code' => CurrencyFormatter::formatCode($crmData['CurrencyIsoCode'] ?? null),\n 'close_date' => $closeDate,\n 'is_closed' => $crmData['IsClosed'],\n 'is_won' => $crmData['IsWon'],\n 'stage_id' => $stage?->id ?? null,\n 'record_type_id' => $recordType->id ?? null,\n 'remotely_created_at' => $createdDate,\n 'probability' => $crmData['Probability'] ?? null,\n 'forecast_category' => $crmData['ForecastCategoryName'] ?? null,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->opportunities(), $crmData['Id']);\n\n // Do not allow locked DB tables & other errors\n // to interrupt the process of reverting the trashed opportunities\n try {\n /** @var Opportunity $opportunity */\n $opportunity = $this->config->opportunities()\n ->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n // import external fields into crm_field_data if present\n $crmFields = $this->getOpportunitySyncableFields();\n\n $this->importOpportunityCrmFieldData($crmData, $crmFields, $opportunity->id);\n\n $this->handleObjectDeletion($opportunity, $crmData);\n\n if ($opportunity->wasRecentlyCreated) {\n MatchActivitiesToNewOpportunity::dispatch($opportunity->getId());\n }\n\n return $opportunity;\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n $this->logger->error('[Salesforce] importOpportunity failure.', [\n 'crm_provider_id' => $crmData['Id'],\n 'team_id' => $this->team->id,\n 'exception' => $exception->getMessage(),\n ]);\n\n $this->handleEntityDeletionByProviderId($this->config->opportunities(), $crmData);\n }\n\n return null;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContacts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n $fields = $this->getAllFieldsAsArray('contact');\n if (\\in_array('Id', $fields, true) === false) {\n return $syncCount;\n }\n\n $query = '\n SELECT ' . rtrim(implode(',', $fields), ',') . '\n FROM Contact\n WHERE LastModifiedDate > :since\n ORDER BY LastModifiedDate ASC';\n\n try {\n $sfContacts = $this->queryHandler->query($query, [\n 'since' => $since->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($sfContacts as $sfContact) {\n // Only sync if previously imported.\n if ($this->hasContact($sfContact['Id'])) {\n $this->importContact($sfContact);\n $syncCount++;\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::CONTACT);\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContact(string $crmId): ?Contact\n {\n $fields = $this->getAllFieldsAsArray('contact');\n if (! in_array('Id', $fields, true)) {\n $this->logger->info('[Salesforce] Sync contact cancelled. Fields are not available.', [\n 'crmId' => $crmId,\n 'userId' => $this->profile->getUserId(),\n ]);\n\n return null;\n }\n\n $sfContact = $this->getRecord('Contact', $crmId, $fields);\n\n return $this->importContact($sfContact);\n }\n\n private function importContact($crmData): Contact\n {\n $account = null;\n // Contacts may not have accounts...\n if (isset($crmData['AccountId'])) {\n $account = $this->config->accounts()\n ->where('crm_provider_id', (string) $crmData['AccountId'])\n ->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmData['AccountId']);\n }\n }\n\n $countryCode = $crmData['MailingCountryCode'] ?? null;\n\n // Salesforce allows custom \"countries\" to be created. Disregard these.\n if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {\n $countryCode = null;\n }\n\n // If we have no country code, try to parse it from the country name.\n if ($countryCode === null && empty($crmData['MailingCountry']) === false) {\n $countryCode = $this->convertCountryNameToCode($crmData['MailingCountry']);\n\n if ($countryCode === null && $account) {\n $countryCode = $account->country_code;\n }\n }\n\n $ext = null;\n $parsedNumber = [];\n if (empty($crmData['Phone']) === false) {\n $number = Str::limit($crmData['Phone'], 25, '');\n $parsedNumber = parsePhoneNumber($countryCode, $number);\n\n if (empty($parsedNumber['ext']) === false) {\n $ext = Str::limit($parsedNumber['ext'], 10, '');\n }\n }\n\n $mobileNumber = null;\n if (empty($crmData['MobilePhone']) === false) {\n $mobileNumber = Str::limit(phone_e164($countryCode, $crmData['MobilePhone']), 25, '');\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $data = [\n 'team_id' => $this->team->id,\n 'account_id' => $account->id ?? null,\n 'user_id' => $profile?->user_id,\n 'owner_id' => $crmData['OwnerId'] ?? null,\n 'name' => ($crmData['Name'] ?? null) !== null ? mb_strimwidth($crmData['Name'], 0, 100) : '',\n 'title' => ($crmData['Title'] ?? null) !== null ? mb_strimwidth($crmData['Title'], 0, 128) : null,\n 'email' => ($crmData['Email'] ?? null) !== null ? mb_strimwidth($crmData['Email'], 0, 191) : null,\n 'country_code' => $countryCode,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'ext' => $ext,\n 'mobile_phone' => $mobileNumber,\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['Id'],\n modelType: Contact::class,\n fileName: $crmData['Id'],\n avatarText: $crmData['Name']\n ),\n 'remotely_created_at' => $createdDate,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->contacts(), $crmData['Id']);\n\n /** @var Contact $contact */\n $contact = $this->config->contacts()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n $this->handleObjectDeletion($contact, $crmData);\n\n return $contact;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n $fields = [\n 'InstanceName',\n 'OrganizationType',\n 'IsSandbox',\n ];\n\n $orgValues = $this->getRecord('Organization', $this->config->crm_provider_id, $fields);\n\n $edition = null;\n switch ($orgValues['OrganizationType']) {\n case 'Developer Edition':\n $edition = Configuration::EDITION_DEVELOPER;\n\n break;\n\n case 'Professional Edition':\n $edition = Configuration::EDITION_PROFESSIONAL;\n\n break;\n\n case 'Enterprise Edition':\n $edition = Configuration::EDITION_ENTERPRISE;\n\n break;\n }\n\n $this->config->edition = $edition;\n $this->config->instance = $orgValues['InstanceName'];\n\n // XXX: How can this state be possible?\n if ($this->config->version === null) {\n $this->config->version = Client::MIN_API_VERSION;\n }\n\n $installedVersion = $this->getInstalledAppVersion();\n if ($installedVersion !== null) {\n $installedVersion = (string) $this->getInstalledAppVersion();\n }\n\n $this->config->installed_app_version = $installedVersion;\n\n $this->config->save();\n }\n\n public function getInstalledAppVersion(): ?string\n {\n try {\n $query = '\n SELECT\n SubscriberPackageVersion.MajorVersion,\n SubscriberPackageVersion.MinorVersion,\n SubscriberPackageVersion.PatchVersion,\n SubscriberPackageVersion.BuildNumber\n FROM\n InstalledSubscriberPackage\n WHERE\n SubscriberPackageId = :packageId\n ';\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'packageId' => self::INSTALLED_PACKAGE_ID,\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n // Grab version number.\n $version = $sfField['SubscriberPackageVersion']['MajorVersion'] .\n $sfField['SubscriberPackageVersion']['MinorVersion'] .\n $sfField['SubscriberPackageVersion']['PatchVersion'] .\n $sfField['SubscriberPackageVersion']['BuildNumber'];\n } catch (\\Exception) {\n $version = null;\n }\n\n return $version;\n }\n\n /**\n * Store transcripts as note.\n *\n * @throws \\Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // For SF we also check if Log Notes is enabled.\n if ($this->profile->log_notes === Profile::LOG_NOTE_NONE) {\n return;\n }\n\n if ($activity->opportunity_id && $activity->prospect === null) {\n return;\n }\n\n try {\n $transcriptionData = $this->generateTranscription($activity);\n\n $noteMaxLength = $this->profile->log_notes === Profile::LOG_NOTE_ENHANCED\n ? self::ENHANCED_NOTE_MAX_LENGTH\n : self::CLASSIC_NOTE_MAX_LENGTH;\n\n $title = 'Transcript for ';\n $title .= $activity->title ?? $activity->activity_title;\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $body = mb_strimwidth($transcriptionData, 0, $noteMaxLength);\n\n if ($activity->opportunity_id) {\n $objectId = $activity->opportunity->crm_provider_id;\n } else {\n $objectId = $activity->prospect->crm_provider_id;\n }\n\n $noteId = $this->saveNote($title, $body, $objectId);\n\n // Store crm logged id in transcription.\n $transcription = $activity->getTranscription();\n $transcription->crm_activity_id = $noteId;\n $transcription->save();\n } catch (\\Exception $e) {\n \\Sentry::captureException($e);\n }\n }\n\n public function saveNote(string $title, string $body, string $objectId, ?NoteObject $noteObject = null): ?string\n {\n $noteId = null;\n\n try {\n if ($this->profile->log_notes === Profile::LOG_NOTE_ENHANCED) {\n $noteId = $this->buildEnhancedNote($title, $body, $objectId);\n } else {\n $noteId = $this->buildClassicNote($title, $body, $objectId);\n }\n } catch (HttpNotFoundException $exception) {\n // The profile not having access to create Enhanced Notes. Set their preference to Classic.\n if ($this->profile->log_notes === Profile::LOG_NOTE_ENHANCED) {\n $this->profile->update([\n 'log_notes' => Profile::LOG_NOTE_CLASSIC,\n ]);\n }\n }\n\n return $noteId;\n }\n\n /**\n * This is using the \"Enhanced\" Notes feature, NOT the \"Notes & Attachments\" feature being deprecated.\n *\n * @url https://salesforce.stackexchange.com/questions/104408/how-can-i-create-an-account-note-or-contact-note-via-api-that-is-visible-in-sale\n */\n private function buildEnhancedNote(string $title, string $body, string $objectId): string\n {\n // Decode stored entities, escape HTML (without quoting), then convert line breaks for Salesforce formatting\n $decodedBody = html_entity_decode($body, ENT_QUOTES | ENT_HTML5);\n $sanitizedBody = htmlspecialchars($decodedBody, ENT_NOQUOTES, 'UTF-8', false);\n $content = nl2br($sanitizedBody, false);\n $note = [\n 'OwnerId' => $this->profile->crm_provider_id,\n 'Title' => $title,\n 'Content' => base64_encode($content),\n ];\n\n $noteId = $this->createRecord('ContentNote', $note);\n\n $link = [\n 'ContentDocumentId' => $noteId,\n 'LinkedEntityId' => $objectId,\n 'ShareType' => 'I',\n ];\n\n $this->createRecord('ContentDocumentLink', $link);\n\n return $noteId;\n }\n\n private function buildClassicNote(string $title, string $body, string $objectId): string\n {\n if (in_array($this->parseObjectType($objectId), [Field::OBJECT_TASK, Field::OBJECT_EVENT])) {\n $this->logger->info('[Salesforce] Summary not sent', [\n 'profile_id' => $this->profile->id,\n 'objectId' => $objectId,\n 'reason' => 'Classical Note does not support Task/Event relation',\n ]);\n\n return '';\n }\n\n $titleTrimmed = null;\n\n if (mb_strlen($title) > 80) {\n $titleTrimmed = substr($title, 0, 77) . '...';\n }\n $payload = [\n 'OwnerId' => $this->profile->crm_provider_id,\n 'IsPrivate' => false,\n 'Title' => $titleTrimmed ?? $title,\n 'Body' => $titleTrimmed ? $title . PHP_EOL . $body : $body,\n 'ParentId' => $objectId,\n ];\n\n return $this->createRecord('Note', $payload);\n }\n\n /**\n * @inheritdoc\n */\n public function find(string $name, array $scopes): array\n {\n if ($this->profile === null) {\n return [];\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $limitValues = ['limit' => $this->limit, 'offset' => $this->offset];\n $sosl = $queryBuilder->buildFindQuery($name, $scopes, $limitValues);\n\n $this->logger->info('[Salesforce] Find prospects', [\n 'profile_id' => $this->profile->id,\n 'sosl_query' => $sosl,\n 'search_string' => $name,\n 'scopes' => $scopes,\n ]);\n\n $data = Cache::remember($this->profile->id . $sosl, self::CACHE_TTL, function () use ($sosl) {\n $data = [];\n\n try {\n // Hit remote API.\n $objects = $this->queryHandler->search($sosl);\n\n // Build mapped list.\n foreach ($objects as $object) {\n $type = strtolower($object['attributes']['type']);\n\n $record = [\n 'crmId' => $object['Id'],\n 'name' => $object['Name'],\n 'prospectType' => $type,\n 'phoneNumbers' => [],\n 'crmUrl' => $this->generateProviderUrl($object['Id'], $type),\n ];\n\n switch ($type) {\n case 'lead':\n if (empty($object['Company']) === false) {\n $record['organization'] = $object['Company'];\n }\n\n if (empty($object['Title']) === false) {\n $record['title'] = $object['Title'];\n }\n\n $stage = $this->config->stages()\n ->where('type', Stage::TYPE_LEAD)\n ->where('name', $object['Status'])\n ->first();\n\n // Lazy create the stage.\n if ($stage === null) {\n $stage = $this->importStages([Stage::TYPE_LEAD], $object['Status']);\n }\n\n if ($stage) {\n $record += [\n 'stage' => [\n 'id' => $stage->id_string,\n 'name' => $stage->name,\n ],\n ];\n }\n\n if (empty($object['RecordTypeId']) === false) {\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $object['RecordTypeId'])\n ->first();\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n }\n\n break;\n\n case 'account':\n if (empty($object['Industry']) === false) {\n $record['industry'] = $object['Industry'];\n $record['detailsLine'] = $object['Industry'];\n }\n if (! empty($object['PersonEmail'])) {\n $record['detailsLine'] = $object['PersonEmail'];\n }\n\n break;\n\n case 'contact':\n // For contacts, we should try and fetch their account name too.\n if ($object['AccountId']) {\n // Cheaper to get this locally.\n $account = $this->config->accounts()\n ->where('crm_provider_id', $object['AccountId'])\n ->first(['name']);\n\n if ($account) {\n $record['organization'] = $account->name;\n }\n }\n\n if (! empty($object['IsPersonAccount']) && $object['Email']) {\n $record['detailsLine'] = $object['Email'];\n } else {\n if (empty($object['Title']) === false) {\n $record['title'] = $object['Title'];\n }\n }\n\n break;\n }\n\n // Add phone numbers to record.\n if (empty($object['Phone']) === false && $object['Phone']) {\n $record['phoneNumbers'][] = [\n 'number' => $object['Phone'],\n 'nationalFormat' => phone_national($this->profile->user->country_code, $object['Phone']),\n 'type' => 'phone',\n ];\n }\n\n if (empty($object['MobilePhone']) === false && $object['MobilePhone']) {\n $record['phoneNumbers'][] = [\n 'number' => $object['MobilePhone'],\n 'nationalFormat' => phone_national(\n $this->profile->user->country_code,\n $object['MobilePhone']\n ),\n 'type' => 'mobile',\n ];\n }\n\n $data[] = $record;\n }\n } catch (NoResultsException $e) {\n $data = [];\n }\n\n return $data;\n });\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n\n if ($crmAccountId === null) {\n return $data;\n }\n\n if ($userId) {\n $profileRepository = app(ProfileRepository::class);\n $profile = $profileRepository->findProfileByUserId($this->config, $userId);\n\n $ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;\n }\n\n try {\n // Perhaps their profile has no opportunity permissions.\n if ($this->profile === null || $this->profile->opportunity_fields === null) {\n return $data;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $query = $queryBuilder->buildFindOpportunitiesQuery();\n\n $objects = $this->queryHandler->query($query, ['accountId' => $crmAccountId]);\n\n foreach ($objects as $object) {\n $record = [\n 'crmId' => $object['Id'],\n 'name' => $object['Name'],\n 'won' => $object['IsWon'],\n 'closed' => $object['IsClosed'],\n ];\n\n $valueFieldName = 'Amount';\n if ($this->config->opportunity_value_field_id) {\n $valueFieldName = $this->config->opportunityValueField->crm_provider_id;\n }\n\n if (empty($object[$valueFieldName]) === false) {\n $currency = $object['CurrencyIsoCode'] ?? $this->config->default_currency;\n $value = formatCurrency($object[$valueFieldName], $currency);\n\n $record += [\n 'value' => $value,\n ];\n }\n\n $stage = $this->config->stages()\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->where('name', $object['StageName'])\n ->first();\n\n // Lazy create the stage.\n if ($stage === null) {\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $object['StageName']);\n }\n\n $record += [\n 'stage' => [\n 'id' => $stage->id_string,\n 'name' => $stage->name,\n ],\n ];\n\n if (empty($object['RecordTypeId']) === false) {\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $object['RecordTypeId'])\n ->first();\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n }\n\n if ($ownerId && isset($object['OwnerId']) && $object['OwnerId'] === $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n } catch (NoResultsException $e) {\n return $data;\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n public function getContactRolesFromCrm(?Carbon $since = null): array\n {\n $roles = [];\n\n if ($this->profile === null) {\n return $roles;\n }\n\n $queryBuilder = app(QueryBuilder::class, ['profile' => $this->profile]);\n\n $query = $queryBuilder->buildGetContactRolesQuery($since);\n\n try {\n $objects = $this->queryHandler->query($query);\n\n foreach ($objects as $object) {\n $roles[] = [\n 'id' => $object['Id'],\n 'contactId' => $object['ContactId'],\n 'opportunityId' => $object['OpportunityId'],\n 'ownerId' => $object['Opportunity']['OwnerId'] ?? null,\n 'isPrimary' => $object['IsPrimary'],\n 'role' => $object['Role'],\n ];\n }\n } catch (NoResultsException) {\n // Just return an empty array.\n $this->logger->info('[Salesforce] No contact roles found', [\n 'since' => $since?->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n }\n\n return $roles;\n }\n\n public function syncContactRoles(Carbon $since): int\n {\n $contactRoleRepository = app(ContactRoleRepository::class);\n\n $crmContactRoles = $this->getContactRolesFromCrm(since: $since);\n $syncCount = 0;\n $contactRoles = [];\n\n foreach ($crmContactRoles as $crmContactRole) {\n $contactRoles[] = $this->importContactRole($crmContactRole);\n $syncCount++;\n }\n\n $contactRoleRepository->saveContactRoles($contactRoles);\n\n $this->syncRemotelyDeletedContactRoles();\n\n return $syncCount;\n }\n\n private function importContactRole(array $contactRole): array\n {\n $contact = $this->config->contacts()\n ->where('crm_provider_id', $contactRole['contactId'])\n ->first();\n\n if ($contact === null) {\n $contact = $this->syncContact($contactRole['contactId']);\n }\n\n $opportunity = $this->config->opportunities()\n ->where('crm_provider_id', $contactRole['opportunityId'])\n ->first();\n\n if ($opportunity === null) {\n $opportunity = $this->syncOpportunity($contactRole['opportunityId']);\n }\n\n $role = null;\n if (! empty($contactRole['role'])) {\n $role = mb_strimwidth($contactRole['role'], 0, 191);\n }\n\n return [\n 'crm_configuration_id' => $this->config->getId(),\n 'contact_id' => $contact->getId(),\n 'crm_provider_id' => $contactRole['id'],\n 'subject_type' => ContactRole::SUBJECT_TYPE_OPPORTUNITY,\n 'subject_id' => $opportunity->getId(),\n 'is_primary' => $contactRole['isPrimary'],\n 'role' => $role,\n ];\n }\n\n protected function syncRemotelyDeletedContactRoles(): bool\n {\n try {\n $deletedRemotely = $this->queryHandler->queryDeleted('OpportunityContactRole');\n } catch (NoResultsException $e) {\n return false;\n }\n\n $deletedOpportunities = $deletedRemotely->getResults();\n $deletedIds = array_column($deletedOpportunities, 'id');\n\n $contactRoleRepository = app(ContactRoleRepository::class);\n\n foreach (array_chunk($deletedIds, self::HARD_DELETE_CHUNK) as $chunk) {\n $contactRoleRepository->deleteContactRoles($chunk);\n\n $this->logger->info('[' . $this->getDisplayName() . '] Remotely deleted opportunities synced', [\n 'teamId' => $this->team->id_string,\n 'remotelyDeletedOpportunities' => $chunk,\n 'count' => count($chunk),\n ]);\n }\n\n return true;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = $objects = [];\n\n $hasWho = \\in_array($objectType, ['lead', 'contact']);\n $playbook = $this->getPlaybook($this->profile->user);\n $fields = array_merge(\n $this->profile->getFieldsAsArray(parent::OBJECT_TASK),\n $playbook && $playbook->activityField ? [$playbook->activityField->crm_provider_id] : []\n );\n\n // Query should default to any open call for that user.\n $query = '\n SELECT ' . implode(',', array_unique($fields)) . '\n FROM Task\n WHERE OwnerId = :ownerId\n AND IsArchived = false\n AND IsDeleted = false\n AND IsClosed = false\n AND (';\n\n if ($objectType === 'account') {\n // This covers tasks tied to a related contact or opportunity too.\n $query .= '\n AccountId = :accountId';\n }\n\n if ($hasWho) {\n $query .= '\n WhoId = :whoId';\n\n // If we are also going to check on a specific opportunity, set that up.\n if ($opportunityId) {\n $query .= ' OR WhatId = :whatId';\n }\n }\n\n $query .= ' ) ORDER BY LastModifiedDate DESC';\n\n try {\n $objects = $this->queryHandler->query($query, [\n 'ownerId' => $this->profile->crm_provider_id,\n 'whoId' => $objectId,\n 'whatId' => $opportunityId,\n 'accountId' => $objectId,\n ]);\n } catch (NoResultsException $e) {\n return $data;\n } finally {\n $this->logger->debug(sprintf('[Salesforce] Found %s tasks for query \"%s\"', count($objects), $query));\n }\n\n foreach ($objects as $object) {\n $dueDate = $object['ActivityDate'] ? Carbon::parse($object['ActivityDate'])->toIso8601String() : null;\n $data[] = [\n 'crmId' => $object['Id'],\n 'subject' => $object['Subject'],\n 'due' => $dueDate,\n 'type' => $object[$playbook->activityField->crm_provider_id],\n ];\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getEvents(string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = $objects = [];\n $user = $this->profile?->user;\n if ($this->profile === null || $user === null) {\n return $data;\n }\n\n $hasWho = \\in_array($objectType, ['lead', 'contact']);\n $playbook = $this->getPlaybook($user);\n $fields = array_merge(\n $this->profile->getFieldsAsArray(parent::OBJECT_EVENT),\n $playbook && $playbook->activityField ? [$playbook->activityField->crm_provider_id] : []\n );\n\n // Query should default to any event starting in the last week and ending up until today owned by the user.\n $query = '\n SELECT ' . implode(',', array_unique($fields)) . '\n FROM Event\n WHERE OwnerId = :ownerId\n AND IsArchived = false\n AND IsAllDayEvent = false\n AND StartDateTime >= LAST_N_DAYS:7\n AND EndDateTime <= TODAY\n AND (';\n\n if ($objectType === 'account') {\n // This covers events tied to a related contact or opportunity too.\n $query .= '\n AccountId = :accountId';\n }\n\n if ($hasWho) {\n $query .= '\n WhoId = :whoId';\n\n // If we are also going to check on a specific opportunity, set that up.\n if ($opportunityId) {\n $query .= ' OR WhatId = :whatId';\n }\n }\n\n $query .= ' ) ORDER BY LastModifiedDate DESC';\n\n try {\n $objects = $this->queryHandler->query($query, [\n 'ownerId' => $this->profile->crm_provider_id,\n 'whoId' => $objectId,\n 'whatId' => $opportunityId,\n 'accountId' => $objectId,\n ]);\n } catch (NoResultsException $e) {\n return $data;\n } finally {\n $this->logger->debug(sprintf('[Salesforce] Found %s tasks for query \"%s\"', count($objects), $query));\n }\n\n foreach ($objects as $object) {\n $dueDate = $object['StartDateTime'] ? Carbon::parse($object['StartDateTime'])->toIso8601String() : null;\n\n $data[] = [\n 'crmId' => $object['Id'],\n 'subject' => $object['Subject'],\n 'due' => $dueDate,\n 'type' => $object[$playbook->activityField->crm_provider_id],\n ];\n }\n\n return $data;\n }\n\n /**\n * Try to find CRM Objects using email address\n *\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchExactlyByEmail(string $email, ?int $userId = null): ?array\n {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $sosl = $queryBuilder->buildMatchByQuery($email, Field::TYPE_EMAIL);\n if ($sosl === null) {\n return null;\n }\n\n try {\n $objects = $this->queryHandler->search($sosl);\n $objects = $this->queryHandler->prioritiseResults(\n $objects,\n $email,\n QueryHandler::PRIORITISE_EMAIL\n );\n\n $data = $this->convertCrmData($objects, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (NoResultsException $e) {\n // Try the account next.\n if ($this->profile->account_fields === null) {\n return null;\n }\n }\n\n return null;\n }\n\n public function getDomain(string $email): ?string\n {\n // SF improved search - strip the domain extension, min domain name length 4\n return $this->getCompanyNameFromEmail(email: $email, minNameLength: 4);\n }\n\n /**\n * Try to find CRM objects using domain name of the email address\n *\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByDomain(string $domain, ?int $userId = null): ?array\n {\n $companyName = $domain;\n\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $sosl = $queryBuilder->buildMatchByDomainQuery($companyName);\n\n try {\n $objects = $this->queryHandler->search($sosl);\n\n $data = $this->convertCrmData($objects, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (NoResultsException) {\n return null;\n }\n }\n\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n // Don't bother looking up numbers that are masked.\n if (str_contains($phone, '**')) {\n return null;\n }\n\n if ($this->isPhoneNumberOfTeamMember($phone)) {\n return null;\n }\n\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $phoneNational = phone_national(null, $phone) ?? '';\n $possiblePhoneFormats = collect([\n preg_replace('/\\D/', '', ltrim($phone, '0+')),\n preg_replace('/\\D/', '', $phoneNational),\n formatDashPhoneNumber($phone),\n $phoneNational,\n ])\n ->filter() // Removes null and empty strings\n ->unique()\n ->values();\n\n foreach ($possiblePhoneFormats as $phone) {\n $sosl = $queryBuilder->buildMatchByQuery($phone, Field::TYPE_PHONE);\n if ($sosl === null) {\n continue;\n }\n\n try {\n $objects = $this->queryHandler->search($sosl);\n $objects = $this->queryHandler->prioritiseResults(\n $objects,\n $phone,\n QueryHandler::PRIORITISE_PHONE\n );\n\n return $this->convertCrmData($objects, $userId);\n } catch (NoResultsException) {\n continue;\n }\n }\n\n return null;\n }\n\n private function isPhoneNumberOfTeamMember(string $phone): bool\n {\n $teamRepository = app(TeamRepository::class);\n $user = $teamRepository->findTeamMemberByPhone($this->team, $phone);\n\n if ($user instanceof User) {\n return true;\n }\n\n return false;\n }\n\n protected function getCacheKey(string $object, ?int $userId = null): ?string\n {\n $key = $this->profile->id . $object;\n $keySuffix = $this->getOwnerKeySuffix($userId);\n\n return $key . $keySuffix;\n }\n\n private function getOwnerKeySuffix(?int $userId = null): string\n {\n return $userId === null ? '' : (string) $userId;\n }\n\n /** Determine the CRM Objects which represent the call activity. */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n if ($this->profile === null) {\n return null;\n }\n\n $cacheKey = $this->getCacheKey($name, $userId);\n\n $result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $sosl = $queryBuilder->buildMatchByQuery($name, 'name');\n if ($sosl === null) {\n return false;\n }\n\n try {\n $objects = $this->queryHandler->search($sosl);\n } catch (NoResultsException $e) {\n return false;\n }\n\n $objects = $this->queryHandler->prioritiseResults(\n $objects,\n $name,\n QueryHandler::PRIORITISE_NAME\n );\n\n $data = $this->convertCrmData($objects, $userId);\n\n return (! empty(array_filter($data))) ? $data : false;\n });\n\n return is_array($result) ? $result : null;\n }\n\n /**\n * @return array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n protected function convertCrmData(QueryIterator $objects, ?int $userId = null): array\n {\n $lead = null;\n $contact = null;\n $opportunity = null;\n $account = null;\n $stage = null;\n $countryCode = null;\n\n if ($objects->count() > 0) {\n $object = $objects->current();\n\n if ($object['attributes']['type'] === 'Lead') {\n $lead = $this->importLead($object);\n\n // Lead might not be imported if the Stage is null for example.\n if ($lead) {\n $countryCode = $lead->country_code;\n $stage = $lead->stage;\n }\n } else {\n if ($object['attributes']['type'] === 'Contact') {\n $contact = $this->importContact($object);\n $account = $contact->account;\n } else {\n $account = $this->importAccount($object);\n }\n\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account) {\n $countryCode = $account->country_code;\n }\n\n try {\n $sfOpportunities = $this->findOpportunities(\n $account?->getCrmProviderId(),\n $contact?->getCrmProviderId(),\n $userId\n );\n\n // Take the first opportunity, which will be ordered as priority based on their settings.\n if (! empty($sfOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($sfOpportunities[0]['crmId']);\n $stage = $opportunity?->stage;\n }\n } catch (Exception) {\n // Nothing to see here.\n }\n }\n }\n\n return [\n $lead,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n /**\n * @inheritdoc\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n if ($stage->type === Stage::TYPE_LEAD) {\n $objectType = 'Lead';\n $objectId = $crmObject->crm_provider_id;\n $objectStageType = 'Status';\n } else {\n $objectType = 'Opportunity';\n $objectId = $crmObject->crm_provider_id;\n $objectStageType = 'StageName';\n }\n\n $headers = [];\n if ($this->config->trigger_assignment_rules === false) {\n // @see: https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/headers_autoassign.htm\n $headers = [\n 'Sforce-Auto-Assign' => 'false',\n ];\n }\n\n $this->updateRecord($objectType, $objectId, [$objectStageType => $stage->name], $headers);\n }\n\n public function parseObjectType(string $objectId): string\n {\n if (Str::startsWith($objectId, '001')) {\n return 'account';\n }\n\n if (Str::startsWith($objectId, '003')) {\n return 'contact';\n }\n\n if (Str::startsWith($objectId, '00Q')) {\n return 'lead';\n }\n\n if (Str::startsWith($objectId, '006')) {\n return 'opportunity';\n }\n\n if (Str::startsWith($objectId, '00U')) {\n return 'event';\n }\n\n if (Str::startsWith($objectId, '00T')) {\n return 'task';\n }\n\n throw new \\InvalidArgumentException('Unsupported Object Type');\n }\n\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, ['profile' => $this->profile]);\n $query = $queryBuilder->buildGetUsersQuery($userToSearch);\n\n try {\n $salesforceUsers = $this->queryHandler->query($query, [\n 'active' => true,\n ]);\n } catch (NoResultsException $e) {\n $this->logger->info('[Salesforce] Sync Profiles. No users found', [\n 'query' => $query,\n 'error' => $e->getMessage(),\n ]);\n\n return null;\n }\n\n $teamRepository = app(TeamRepository::class);\n $customRules = $this->getCustomProfileRules($teamRepository);\n\n foreach ($salesforceUsers as $crmUser) {\n if ($crmUser['Email'] === null) {\n continue;\n }\n\n if (! $this->customProfileValidation($crmUser, $customRules)) {\n continue;\n }\n\n $user = $teamRepository->findActiveTeamMemberByEmail($this->team, $crmUser['Email']);\n\n if (! $user instanceof User) {\n continue;\n }\n\n $edition = $crmUser['UserPreferencesLightningExperiencePreferred']\n ? Profile::EDITION_LIGHTNING\n : Profile::EDITION_CLASSIC;\n\n $profileRepository = app(ProfileRepository::class);\n $profile = $profileRepository->updateOrCreateProfile(\n $user,\n [\n 'crm_configuration_id' => $this->config->getId(),\n 'crm_provider_id' => $crmUser['Id'],\n ],\n [\n 'user_id' => $user->getId(),\n 'edition' => $edition,\n 'has_external_cti' => ! empty($crmUser['CallCenterId']),\n 'crm_profile_id' => $crmUser['ProfileId'],\n ]\n );\n\n if ($userToSearch instanceof User && $userToSearch->getId() === $user->getId()) {\n return $profile;\n }\n }\n\n // Clean up inactive profiles\n try {\n $this->archiveInactiveProfiles();\n } catch (\\Exception $e) {\n $this->logger->warning('[Salesforce] Profile archiving failed', [\n 'teamId' => $this->team->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $url = null;\n\n // For Salesforce it's easy, we just point every object to the apex domain and they handle it.\n switch ($objectType) {\n case 'lead':\n case 'account':\n case 'contact':\n case 'opportunity':\n case 'task':\n case 'event':\n case 'activity':\n\n $url = $this->config->crm_base_url . '/' . $providerId;\n\n break;\n }\n\n return $url;\n }\n\n public function buildTaskSearchFields(): array\n {\n return ['Id', 'WhoId', 'WhatId', 'AccountId'];\n }\n\n public function getTaskByFilterConditions(\n array $fields,\n array $filters,\n bool $bulkSearch = false,\n bool $strictFilters = true\n ): ?array {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $query = $queryBuilder->buildSearchTaskQuery($fields, $filters, $bulkSearch, $strictFilters);\n\n try {\n if (! $bulkSearch) {\n $objects = $this->queryHandler->query($query, $filters);\n if ($objects->count() === 1) {\n return $objects->current();\n }\n }\n\n if ($bulkSearch) {\n $objects = $this->queryHandler->query($query);\n $records = [];\n foreach ($objects as $record) {\n $key = $record[end($fields)];\n $records[$key] = $record;\n }\n\n return $records;\n }\n } catch (\\Exception $e) {\n $this->logger->info('[Salesforce] Failed to execute query', [\n 'query' => $query,\n 'error' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function mapCrmObjects(array $task): array\n {\n $activityData = [];\n\n if (! empty($task['WhoId'])) {\n $type = $this->parseObjectType($task['WhoId']);\n $activityData[$type] = $task['WhoId'];\n }\n if (! empty($task['AccountId'])) {\n $activityData['account'] = $task['AccountId'];\n }\n if (! empty($task['WhatId'])) {\n $activityData['opportunity'] = $task['WhatId'];\n }\n\n return $activityData;\n }\n\n /**\n * Get SF task by Outreach call id.\n */\n public function getTaskByFilter(\n string $activityFieldType,\n array $filters,\n string $operator = '=',\n array $additionalFields = []\n ): ?array {\n $data = [];\n\n try {\n // Default (base) fields.\n $fields = ['Id', 'Subject', 'Description', 'ActivityDate', 'WhoId', 'WhatId', $activityFieldType];\n\n foreach ($additionalFields as $additionalField) {\n $fields[] = $additionalField->crm_provider_id;\n }\n\n $fields = array_unique($fields);\n\n // Find task with the same Outreach id as the call id.\n $query = 'SELECT ' . implode(',', $fields) . '\n FROM Task\n WHERE IsArchived = false AND IsDeleted = false';\n\n foreach ($filters as $key => $value) {\n $key = preg_quote($key, '/');\n $key = str_replace(['\\'', '\"'], '', $key);\n // Prepare the substitution.\n $strKey = \":$key\";\n\n $query .= \" AND $key $operator $strKey\";\n }\n\n $query .= ' ORDER BY LastModifiedDate DESC LIMIT 1';\n\n $objects = $this->queryHandler->query($query, $filters);\n\n // There should be only one task related to this call if any.\n if ($objects->count() === 1) {\n $object = $objects->current();\n\n $dueDate = $object['ActivityDate'] ? Carbon::parse($object['ActivityDate'])->toIso8601String() : null;\n\n $data = array_merge($object, [\n 'crmId' => $object['Id'],\n 'subject' => $object['Subject'],\n 'summary' => $object['Description'],\n 'due' => $dueDate,\n 'Type' => $object[$activityFieldType],\n ]);\n }\n } catch (NoResultsException $e) {\n // Filters don't match any records.\n } catch (ServiceUnavailableException $serviceUnavailableException) {\n // Service cannot be queried. We should probably log this.\n }\n\n return $data;\n }\n\n /**\n * Get Salesforce fields including datetime fields\n *\n * @param $objectType\n */\n private function getAllFieldsAsArray($objectType): array\n {\n $basicFields = [];\n // Not all users have access to all object fields.\n if ($this->profile->{$objectType . '_fields'}) {\n $basicFields = explode(',', $this->profile->{$objectType . '_fields'});\n }\n\n $extraFields = [\n 'CreatedDate',\n 'LastModifiedDate',\n 'IsDeleted',\n ];\n\n if ($objectType === self::OBJECT_OPPORTUNITY\n && $this->config->opportunity_value_field_id\n && ! in_array($this->config->opportunityValueField->crm_provider_id, $basicFields)\n ) {\n $extraFields[] = $this->config->opportunityValueField->crm_provider_id;\n }\n\n return array_unique(array_merge($basicFields, $extraFields));\n }\n\n /**\n * Generate transcription for activity description.\n */\n private function generateTranscription(Activity $activity): string\n {\n if (! ($this->config->store_transcript)) {\n // If sending transcription to activity toggle is disabled\n return '';\n }\n\n return $this->transcriptionService\n ->findTranscriptionByActivity($activity)\n ->map(static function (array $transcriptionSegment): string {\n return $transcriptionSegment['formattedStartsAt'] . ' | ' . $transcriptionSegment['transcript'];\n })\n ->implode(PHP_EOL);\n }\n\n /**\n * Find related Salesforce event based on activity data\n *\n * @return array<string>\n */\n public function fetchRelatedActivity(Activity $activity): array\n {\n $this->logger->info('[Salesforce] Searching for related activity', [\n 'activityId' => $activity->getUuid(),\n 'ownerId' => $this->profile?->crm_provider_id,\n ]);\n\n $sfEvent = $this->fetchRelatedEvent($activity);\n if (empty($sfEvent)) {\n $this->logger->info('[Salesforce] No related activity found', [\n 'activityId' => $activity->getUuid(),\n 'ownerId' => $this->profile?->crm_provider_id,\n 'account' => $activity->hasAccount()\n ? $activity->getAccount()->getCrmProviderId()\n : null,\n ]);\n\n return [];\n }\n\n return $sfEvent;\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n if ($activity->isTypeConference() === false) {\n return null;\n }\n\n if ($activity->hasActualStartTime() === false && $activity->hasScheduledStartTime() === false) {\n return null;\n }\n\n if (! $activity->hasProspect()) {\n $this->logger->info('[Salesforce] Skip look up, Activity not linked to Lead, Contact or Account', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n $playbook = $this->getPlaybook($activity->getUser());\n if ($playbook !== null && $playbook->getActivityType() === Playbook::ACTIVITY_TYPE_TASK) {\n $this->logger->info('[Salesforce] Skip auto-sync for task-based playbook', [\n 'activityUuid' => $activity->getUuid(),\n 'playbookId' => $playbook->getId(),\n 'playbookType' => $playbook->getActivityType(),\n ]);\n\n return null;\n }\n\n try {\n $sfEvent = $this->fetchRelatedActivity($activity);\n if (empty($sfEvent)) {\n return null;\n }\n\n [$activityField, $activityType] = $this->resolveActivityTypeFromEvent($activity, $sfEvent);\n\n $this->logger->info('[Salesforce] Found related activity', [\n 'activityId' => $activity->getUuid(),\n 'sfEvent' => $sfEvent['Id'],\n 'activityFieldName' => $activityField,\n 'crmActivityType' => ($activityField !== null && isset($sfEvent[$activityField]))\n ? $sfEvent[$activityField]\n : null,\n 'activityType' => $activityType,\n ]);\n\n $userId = $this->findRelatedActivityUserId($activity, $sfEvent);\n\n if ($activity->getUserId() !== $userId) {\n $this->logger->info('[Salesforce] Updating meeting owner', [\n 'activityId' => $activity->getUuid(),\n 'oldUserId' => $activity->getUserId(),\n 'newUserId' => $userId,\n ]);\n }\n\n $this->updateSfEventDescription($activity, $sfEvent);\n\n $activity->update([\n 'user_id' => $userId,\n 'crm_provider_id' => $sfEvent['Id'],\n 'playbook_category_id' => $activityType->id ?? $activity->getCategory()?->getId(),\n ]);\n\n $this->logger->info('[Salesforce] Activity updated', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return $activity;\n } catch (\\Exception $exception) {\n \\Sentry::captureException($exception);\n\n throw $exception;\n }\n }\n\n /**\n * @param array<string, mixed> $sfEvent\n *\n * @return array{0: string|null, 1: mixed}\n */\n private function resolveActivityTypeFromEvent(Activity $activity, array $sfEvent): array\n {\n $activityField = $this->getActivityFieldName($activity);\n $activityType = null;\n\n if ($activityField !== null && ! empty($sfEvent[$activityField])) {\n $playbook = $this->getPlaybook($activity->getUser());\n $activityType = $this->getPlaybookCategory($playbook, strval($sfEvent[$activityField]));\n }\n\n return [$activityField, $activityType];\n }\n\n /**\n * @param array<string> $sfEvent\n */\n private function findRelatedActivityUserId(Activity $activity, array $sfEvent): int\n {\n $userId = $activity->getUserId();\n\n if (empty($sfEvent['OwnerId']) === false) {\n $profile = $this\n ->config\n ->profiles()\n ->where('crm_provider_id', $sfEvent['OwnerId'])\n ->get()\n ->filter(static function (Profile $profile) use ($activity): bool {\n if (! $activity->isTypeConference()) {\n return ! empty($profile->user) ? $profile->user->isStatusActive() : false;\n }\n\n $participants = $activity->getParticipants();\n\n return ! empty($profile->user)\n ? $profile->user->isStatusActive()\n && $profile->user->hasPermission(PermissionEnum::RECORD_MEETING)\n && $participants->contains('user_id', $profile->user_id)\n : false;\n })\n ->first();\n\n if ($profile) {\n $userId = $profile->user_id;\n }\n }\n\n return $userId;\n }\n\n /**\n * @param array<string, mixed> $sfEvent\n */\n private function updateSfEventDescription(Activity $activity, array $sfEvent): void\n {\n try {\n if (str_contains($sfEvent['Description'], $activity->id_string)) {\n return;\n }\n\n $payload = [\n 'Description' => $sfEvent['Description']\n . PHP_EOL\n . PHP_EOL\n . (new DecorateActivity())->generateDescription($activity),\n ];\n\n $this->logger->info('[Salesforce] Update record', [\n 'activityId' => $activity->getUuid(),\n 'sfEvent' => $sfEvent['Id'],\n 'payload' => $payload,\n ]);\n\n $payload = array_merge(\n $payload,\n $this->payloadBuilder->fetchCustomFieldData($activity, Field::OBJECT_EVENT)\n );\n\n $this->updateRecord('Event', $sfEvent['Id'], $payload);\n } catch (\\Exception) {\n $this->logger->error('[Salesforce] Failed to update record', [\n 'activityUuid' => $activity->getUuid(),\n 'sfEvent' => $sfEvent['Id'],\n ]);\n }\n }\n\n /**\n * Returns the most recently modified Event within time range (if any).\n *\n * @return array|null An Event record from Salesforce.\n */\n private function fetchRelatedEvent(Activity $activity): ?array\n {\n $ownerId = $this->profile?->crm_provider_id;\n if ($ownerId === null) {\n return [];\n }\n\n /** @var ?Carbon $from */\n /** @var ?Carbon $to */\n [$from, $to] = $this->getFromToDates($activity);\n\n try {\n $whoId = null;\n $hasWho = $activity->lead_id || $activity->contact_id;\n if ($hasWho) {\n $whoId = $activity->hasLead()\n ? $activity->getLead()->crm_provider_id\n : $activity->getContact()->crm_provider_id;\n }\n\n if ($hasWho === false && $activity->account_id === null) {\n return null;\n }\n\n $query = $this->buildFetchRelatedEventQuery($activity);\n\n $objects = $this->queryHandler->query($query, [\n 'ownerId' => $ownerId,\n 'whoId' => $whoId,\n 'whatId' => $activity->hasOpportunity() ? $activity->getOpportunity()->crm_provider_id : null,\n 'accountId' => $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null,\n 'from' => $from?->format('Y-m-d\\TH:i:s\\Z'),\n 'to' => $to?->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($objects as $object) {\n return $object;\n }\n } catch (NoResultsException $e) {\n return [];\n }\n\n return [];\n }\n\n private function getFromToDates(Activity $activity): array\n {\n $from = null;\n $to = null;\n\n /** @var ?CalendarEvent $calendarEvent */\n $calendarEvent = $activity->calendarEvent()->first();\n if ($calendarEvent !== null) {\n $from = $calendarEvent->getStartTime();\n $to = $calendarEvent->getEndTime();\n }\n\n // For non-calendar imported activities\n // Also double check if calendar event dates could be null?\n // If null use what we've got so far\n if ($from === null || $to === null) {\n $from = $activity->hasScheduledStartTime()\n ? $activity->getScheduledStartTime()\n : $activity->getActualStartTime();\n $to = $activity->hasScheduledEndTime()\n ? $activity->getScheduledEndTime()->addMinutes(15)\n : $activity->getActualEndTime();\n }\n\n return [$from, $to];\n }\n\n /**\n * Determines the appropriate activity field name for querying Salesforce events.\n *\n * This method follows a hierarchy to determine the field name:\n * 1. Uses the playbook's activity field if it exists and is in the profile's accessible fields\n * 2. Falls back to the default activity field if the profile has no event fields configured\n * 3. Returns null if no suitable field is found\n *\n * @param Activity $activity The activity to determine the field for\n *\n * @return string|null The field name to use in queries, or null if none is available\n */\n private function getActivityFieldName(Activity $activity): ?string\n {\n if ($this->profile === null) {\n $this->logger->warning('[Salesforce] Cannot determine activity field - profile not found', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n $profileEventFields = $this->profile->getFieldsAsArray('event');\n\n if (empty($profileEventFields)) {\n $defaultActivityField = $this->getDefaultActivityField(Field::OBJECT_EVENT);\n $defaultFieldName = $defaultActivityField?->getAttribute('crm_provider_id');\n // Profile not yet synced — fall back to the default activity field.\n // There is a small chance that the profile won't have Default Activity Type field access\n // in which case the query will fail.\n // This is however an edge case and should be reviewed for profile sync issues.\n Sentry::withScope(function (\\Sentry\\State\\Scope $scope) use ($defaultFieldName): void {\n $scope->setContext('details', [\n 'profileId' => $this->profile->id,\n 'defaultField' => $defaultFieldName,\n ]);\n Sentry::captureMessage(\n '[Salesforce] Profile event fields empty, falling back to default activity field.',\n \\Sentry\\Severity::warning()\n );\n });\n\n return $defaultFieldName;\n }\n\n $playbook = $this->getPlaybook($activity->getUser());\n\n if (! is_null($playbook) && ! is_null($playbook->getActivityField())) {\n $playbookFieldName = $playbook->getActivityField()->getAttribute('crm_provider_id');\n\n if (in_array($playbookFieldName, $profileEventFields, true)) {\n return $playbookFieldName;\n }\n\n $this->logger->warning('[Salesforce] Playbook activity field not found in profile fields', [\n 'activityId' => $activity->getUuid(),\n 'playbookField' => $playbookFieldName,\n 'profileId' => $this->profile->id,\n ]);\n }\n\n return null;\n }\n\n private function buildFetchRelatedEventQuery(Activity $activity): string\n {\n $hasWho = $activity->lead_id || $activity->contact_id;\n\n $activityFieldName = $this->getActivityFieldName($activity);\n $fields = array_filter(['Id', 'Description', 'OwnerId', $activityFieldName]);\n\n $ownerCondition = '(OwnerId = :ownerId OR CreatedById = :ownerId)';\n\n $query = '\n SELECT ' . implode(',', $fields) . '\n FROM Event\n WHERE ' . $ownerCondition . '\n AND IsArchived = false\n AND IsAllDayEvent = false\n AND StartDateTime >= :from\n AND EndDateTime <= :to\n AND (';\n\n $operator = '';\n if ($activity->account_id) {\n // This covers events tied to a related contact or opportunity too.\n $query .= 'AccountId = :accountId';\n\n $operator = ' OR ';\n }\n\n if ($hasWho) {\n $query .= $operator . 'WhoId = :whoId';\n\n // If we are also going to check on a specific opportunity, set that up.\n if ($activity->opportunity_id) {\n $query .= ' OR WhatId = :whatId';\n }\n }\n\n $query .= ') ORDER BY LastModifiedDate DESC';\n\n return $query;\n }\n\n public function fetchProspect(array $task): array\n {\n $lead = $account = $opportunity = $contact = $stage = $countryCode = null;\n $externalId = $task['WhoId'] ?? null;\n\n // Lead or Contact\n if ($externalId) {\n try {\n [$lead, $account, $opportunity, $contact, $stage, $countryCode] = $this->parseRecords($externalId);\n } catch (\\InvalidArgumentException $exception) {\n // Invalid object type.\n }\n }\n\n // If we happen to know the opportunity or account from the Task, figure that out.\n if (empty($task['WhatId']) === false) {\n // WhatId could be either Account ID or Opportunity ID.\n // If WhatId is Opportunity ID, get the opportunity and stage from the CRM.\n try {\n [, $account, $opportunity, , $stage, ] = $this->parseRecords($task['WhatId']);\n } catch (\\InvalidArgumentException $exception) {\n // Invalid object type.\n }\n }\n\n return [$lead, $account, $opportunity, $contact, $stage, $countryCode];\n }\n\n /**\n * Save activity transcription summary as note\n */\n public function saveTranscriptionSummaryAsNote(\n ActivityContract $activity,\n string $title,\n string $body,\n ?string $objectId,\n ?NoteObject $noteObject = null,\n ): ?string {\n return $this->saveNote($title, $body, (string) $objectId);\n }\n\n public function getObjectByFilterConditions(string $objectType, array $fields, array $filters): ?array\n {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $query = $queryBuilder->buildObjectSearchQuery($objectType, $fields, $filters);\n\n try {\n $objects = $this->queryHandler->query($query, $filters);\n if ($objects->count() === 1) {\n return $objects->current();\n }\n } catch (\\Exception $e) {\n $this->logger->info('[Salesforce] Failed to execute query', [\n 'query' => $query,\n 'error' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n private function getCustomProfileRules(TeamRepository $teamRepository): array\n {\n $teamSettings = $teamRepository->getTeamSetting($this->team, 'custom_profile_validation');\n\n if ($teamSettings instanceof TeamSettings && $teamSettings->getValueType() === 'array') {\n $customRules = json_decode($teamSettings->getValue(), true);\n if (is_array($customRules)) {\n return $customRules;\n }\n }\n\n return [];\n }\n\n private function customProfileValidation(array $crmUser, array $customRules): bool\n {\n foreach ($customRules as $customRule) {\n if ($crmUser[$customRule['field']] !== $customRule['value']) {\n return false;\n }\n }\n\n return true;\n }\n\n /**\n * When syncing Contact / Lead / Account / Opportunity / Stage crm entities,\n * validate and restore locally trashed objects,\n * before updating them. Objects are identified by CrmProviderId\n */\n private function restoreAnyTrashedEntity(HasMany $targetEntity, string $crmProviderId): void\n {\n $recordExists = $targetEntity->withTrashed()->where(['crm_provider_id' => $crmProviderId])->first();\n if ($recordExists && $recordExists->trashed()) {\n $recordExists->restore();\n }\n }\n\n #[\\Override] public function supportsNotes(): bool\n {\n return true;\n }\n\n private function getOwnerProfile(?string $ownerId): ?Profile\n {\n if ($ownerId === null) {\n return null;\n }\n\n return $this->config->profiles()\n ->where('crm_provider_id', $ownerId)\n ->first();\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"Current version","depth":1,"bounds":{"left":0.4398271,"top":0.43415803,"width":0.13863032,"height":0.013567438},"on_screen":true,"value":"Current version","help_text":"text/plain","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Salesforce;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse Illuminate\\Database\\Eloquent\\Relations\\HasMany;\nuse Illuminate\\Events\\Dispatcher;\nuse Illuminate\\Support\\Facades\\Cache;\nuse Illuminate\\Support\\Facades\\Log;\nuse Illuminate\\Support\\Str;\nuse Jiminny\\Component\\Country\\CountriesMap;\nuse Jiminny\\Contracts\\Acl\\PermissionEnum;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\FetchRelatedActivityInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\ImportsBusinessProcessesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\SalesforceBatchSyncInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\SalesforceInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteNoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SearchTaskInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SendSummaryToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SupportsObjectTypeParseInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmProfileRecordTypesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Enums\\CrmObject;\nuse Jiminny\\Events\\Activities\\Crm\\LeadConverted;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpBadRequestException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Exceptions\\NoResultsException;\nuse Jiminny\\Exceptions\\ServiceUnavailableException;\nuse Jiminny\\Jobs\\Crm\\NoteObject;\nuse Jiminny\\Jobs\\Crm\\MatchActivitiesToNewOpportunity;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Calendar\\CalendarEvent;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Contracts\\ActivityContract;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Configuration;\nuse Jiminny\\Models\\Crm\\ContactRole;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Crm\\RecordType;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\TeamSettings;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\ContactRoleRepository;\nuse Jiminny\\Repositories\\Crm\\FieldRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Repositories\\Crm\\RecordTypeFieldValuesRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\Helpers\\ArrayIterator;\nuse Jiminny\\Services\\Crm\\MatchDomainByEmailInterface;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Services\\Crm\\ResolveCompanyNameByEmailTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\Fields\\FieldHelper;\nuse Jiminny\\Services\\Crm\\Salesforce\\Fields\\FieldTypeConverter;\nuse Jiminny\\Services\\Crm\\Salesforce\\Fields\\ValueNormalizer;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\FollowupActivityTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\LogActivityTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\RecordManipulationsTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\SyncFieldsTrait;\nuse Jiminny\\Utils\\CurrencyFormatter;\nuse Jiminny\\Utils\\StringUtil;\nuse Ramsey\\Uuid\\Uuid;\nuse Sentry\\Laravel\\Facade as Sentry;\n\nclass Service extends BaseService implements\n SalesforceInterface,\n SalesforceBatchSyncInterface,\n SyncCrmEntitiesInterface,\n SyncCrmProfileRecordTypesInterface,\n ImportsBusinessProcessesInterface,\n RemoteEntityManipulationInterface,\n FetchRelatedActivityInterface,\n SendSummaryToCrmInterface,\n MatchDomainByEmailInterface,\n SearchTaskInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n SupportsObjectTypeParseInterface,\n RemoteNoteEntityManipulationInterface,\n VerifyTaskExistsInterface\n{\n use ResolveCompanyNameByEmailTrait;\n use SyncFieldsTrait;\n use DeleteObjectsTrait;\n use RecordManipulationsTrait;\n use ServiceTraits\\BatchSyncTrait;\n use FollowupActivityTrait;\n use LogActivityTrait;\n\n /**\n * Note Body Limit for the Old Note-Taking Tool\n *\n * @var int\n */\n private const int CLASSIC_NOTE_MAX_LENGTH = 32000;\n\n /**\n * Note Content Limit for the New Notes\n *\n * @var int\n */\n private const int ENHANCED_NOTE_MAX_LENGTH = 50000000;\n\n private const string INSTALLED_PACKAGE_ID = '033Tw0000007bKbIAI';\n\n private const int CACHE_TTL = 600;\n\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day - 86400\n\n /**\n * @var Client\n */\n protected $client;\n\n protected PayloadBuilder $payloadBuilder;\n protected QueryHandler $queryHandler;\n\n private OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n\n public function __construct(\n Client $client,\n PayloadBuilder $payloadBuilder,\n protected Dispatcher $eventDispatcher,\n private readonly CountriesMap $countriesMap,\n private readonly ProspectPhotoPathService $prospectPhotoPathService,\n ) {\n parent::__construct();\n\n $this->client = $client;\n $this->payloadBuilder = $payloadBuilder;\n $this->queryHandler = app(QueryHandler::class, [\n 'client' => $this->client,\n 'logger' => $this->logger,\n ]);\n $this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n }\n\n public function getDisplayName(): string\n {\n return 'Salesforce';\n }\n\n public function getJobDelay(): int\n {\n return 1;\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n return $user->getSocialAccount(SocialAccount::PROVIDER_SALESFORCE);\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($activity, $crmProviderId) {\n $playbook = $this->getPlaybookFromActivity($activity);\n\n if ($playbook === null) {\n $this->logger->warning('[Salesforce] Cannot verify task - no playbook found', [\n 'activity' => $activity->getId(),\n 'crm_provider_id' => $crmProviderId,\n ]);\n\n return false;\n }\n\n $objectType = $playbook->getActivityType() === Playbook::ACTIVITY_TYPE_EVENT ? 'Event' : 'Task';\n\n try {\n $record = $this->getRecord($objectType, $crmProviderId, ['Id', 'IsDeleted']);\n\n return ! empty($record) && ($record['IsDeleted'] ?? false) === false;\n } catch (HttpNotFoundException|HttpBadRequestException) {\n $this->logger->info('[Salesforce] Activity record not found during verification', [\n 'activity' => $activity->getId(),\n 'object_type' => $objectType,\n 'crm_provider_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n });\n }\n\n public function query(string $queryToRun, array $parameters = []): QueryIterator\n {\n // Due to poorly designed external calls, this method cannot be entirely removed\n return $this->queryHandler->query($queryToRun, $parameters);\n }\n\n /*=========== Organization Information ===============*/\n\n /**\n * Get a list of all the API Versions for the instance.\n *\n * @throws CrmException\n *\n * @return mixed\n *\n */\n public function getApiVersions()\n {\n $url = $this->config->crm_base_url . '/services/data';\n\n $response = $this->client->get($url);\n\n return json_decode($response->getBody(), true);\n }\n\n /**\n * Gets the valid recordTypes for a given Salesforce Object via the describe API.\n */\n private function getRecordTypes(string $crmObject): array\n {\n $url = $this->client->getObjectsUrl() . $crmObject . '/describe';\n\n $response = $this->client->get($url);\n $jsonResponse = json_decode($response->getBody(), true);\n\n $fields = [];\n foreach ($jsonResponse['recordTypeInfos'] as $row) {\n $fields[] = ['recordTypeId' => $row['recordTypeId'], 'default' => $row['defaultRecordTypeMapping']];\n }\n\n return $fields;\n }\n\n /**\n * Convert raw field data into a format compatible with CRM APIs.\n */\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return ValueNormalizer::normalize($fieldType, $fieldValue, $internal);\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n $defaultFields = ($activityType === Playbook::ACTIVITY_TYPE_TASK)\n ? FieldDefinitions::defaultTaskFields()\n : FieldDefinitions::defaultEventFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n // Setup the activity field as the default Type.\n /** @var Field $activityField */\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'Type',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK, Playbook::ACTIVITY_TYPE_EVENT];\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n\n $fieldFilter = ($activityType === Playbook::ACTIVITY_TYPE_TASK)\n ? FieldDefinitions::taskFollowupFieldsFilter()\n : FieldDefinitions::eventFollowupFieldsFilter();\n\n foreach ($fieldFilter as $eachFilter) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $eachFilter);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n /**\n * This one is now called only when ImportActivityTypes is triggered or SyncFieldMetadata executed manually\n * Regular sync now uses SharedSyncFieldsTrait -> syncSingleObjectType\n * Needs to be replaced later on\n */\n public function syncField(Field $field): void\n {\n try {\n if (FieldHelper::isCustomField($field)) {\n $query = '\n SELECT\n Id, Metadata, TableEnumOrId\n FROM\n CustomField\n WHERE\n DeveloperName = :fieldName\n AND\n TableEnumOrId = :fieldType\n AND\n NamespacePrefix = :namespacePrefix';\n\n // We need to constrain the field lookup to the object, in case it's used in multiple places.\n $objectType = \\in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true)\n ? 'activity'\n : $field->object_type;\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'fieldName' => substr($field->crm_provider_id, 0, -\\strlen('__c')),\n 'fieldType' => ucfirst($objectType),\n\n // This is used to ensure we only consider the field within the org, not installed packages.\n 'namespacePrefix' => 'null',\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n // Sync field metadata.\n $metadata = $sfField['Metadata'];\n\n $field->description = mb_strimwidth($metadata['description'] ?? '', 0, 191);\n $field->label = mb_strimwidth($metadata['label'] ?? '', 0, Field::LABEL_MAX_LENGTH);\n $field->type = $this->convertFieldType($metadata['type'], $field->getEntityName());\n $field->is_mandatory = ($metadata['required'] === true);\n $field->length = $metadata['length'];\n $field->default_value = mb_strimwidth(trim($metadata['defaultValue'] ?? '', '\"'), 0, 191);\n $field->save();\n } else {\n $query = '\n SELECT\n Id, DataType, DeveloperName, Label, Length, Description\n FROM\n FieldDefinition\n WHERE\n DurableId = :entityName';\n\n $entityName = $field->getEntityName();\n $sfFields = $this->queryHandler->metadata($query, [\n 'entityName' => $entityName,\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n $convertedType = $this->convertFieldType($sfField['DataType'], $entityName);\n $label = mb_strimwidth($sfField['Label'], 0, Field::LABEL_MAX_LENGTH);\n\n if ($field->isBusinessType()) {\n $label = 'Opportunity Type';\n }\n\n $field->description = mb_strimwidth($sfField['Description'], 0, Field::DESCRIPTION_MAX_LENGTH);\n $field->label = $label;\n $field->type = $convertedType;\n $field->length = $sfField['Length'];\n $field->save();\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n private function convertFieldType(string $from, ?string $entityName = null): string\n {\n $converter = new FieldTypeConverter();\n\n return $converter->convert($from, $entityName);\n }\n\n /**\n * @inheritdoc\n */\n public function importPicklistValues(Field $field): array\n {\n $values = [];\n $fieldValues = [];\n\n try {\n if (FieldHelper::isCustomField($field)) {\n $query = '\n SELECT\n Id, Metadata, TableEnumOrId\n FROM\n CustomField\n WHERE\n DeveloperName = :fieldName\n AND\n TableEnumOrId = :fieldType\n AND\n NamespacePrefix = :namespacePrefix';\n\n // We need to constrain the field lookup to the object, in case it's used in multiple places.\n $objectType = \\in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true) ?\n 'activity' : $field->object_type;\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'fieldName' => substr($field->crm_provider_id, 0, -\\strlen('__c')),\n 'fieldType' => ucfirst($objectType),\n // This is used to ensure we only consider the field within the org, not installed packages.\n 'namespacePrefix' => 'null',\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n $valueSet = $sfField['Metadata']['valueSet'];\n\n if ($valueSet['valueSetName'] === null) {\n // Local picklist values can be obtained easily.\n $picklistValues = $valueSet['valueSetDefinition']['value'];\n } else {\n // But for some fields, we just get the Global Value Picklist pointer so need to do more work.\n $picklistValues = $this->importGlobalValuePicklistValues($valueSet['valueSetName']);\n }\n\n // Import all active values.\n foreach ($picklistValues as $i => $sfFieldValue) {\n // Setup default value.\n if ($sfFieldValue['default']) {\n $field->update(['default_value' => $sfFieldValue['valueName']]);\n }\n\n // This comes through as null if active (lol).\n if ($sfFieldValue['isActive'] !== false) {\n $values[] = [\n 'value' => $sfFieldValue['valueName'],\n 'label' => $sfFieldValue['valueName'],\n 'sequence' => $i,\n 'is_default' => $sfFieldValue['default'],\n ];\n }\n }\n } else {\n $objectFields = $this->getObjectFields($field->object_type);\n $fieldId = $field->crm_provider_id;\n\n // Only work with our field of interest.\n $objectField = array_filter($objectFields, function ($item) use ($fieldId) {\n return $item['name'] === $fieldId;\n });\n\n $objectField = array_shift($objectField);\n if (empty($objectField['picklistValues']) === false) {\n foreach ($objectField['picklistValues'] as $i => $sfFieldValue) {\n // Skip inactive values.\n if ($sfFieldValue['active'] === false) {\n continue;\n }\n\n // Setup default value.\n if ($sfFieldValue['defaultValue']) {\n $field->update(['default_value' => $sfFieldValue['value']]);\n }\n\n $values[] = [\n 'value' => $sfFieldValue['value'],\n 'label' => $sfFieldValue['label'],\n 'sequence' => $i,\n 'is_default' => $sfFieldValue['defaultValue'],\n ];\n }\n }\n }\n\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n $value['value'] = substr($value['value'] ?? '', 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n if (($key = array_search($value['value'], $fieldsToPurge, true)) !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n // Get IDs of the values to be deleted\n $valuesToDelete = $field->values()->whereIn('value', $fieldsToPurge);\n $valuesToDeleteIds = $valuesToDelete->pluck('id');\n if (! $valuesToDeleteIds->isEmpty()) {\n $recordTypeFieldValuesRepository = app(RecordTypeFieldValuesRepository::class);\n $recordTypeFieldValuesRepository->deleteForCrmFieldValueIds($valuesToDeleteIds->toArray());\n\n // Now safely delete from crm_field_values\n $valuesToDelete->delete();\n }\n\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n return $fieldValues;\n }\n\n /**\n * Gets values from Global Value Picklists.\n */\n private function importGlobalValuePicklistValues(string $picklistName): array\n {\n $query = '\n SELECT\n Metadata\n FROM\n GlobalValueSet\n WHERE\n DeveloperName = :picklistName\n LIMIT 1';\n\n try {\n $sfValues = $this->queryHandler->metadata($query, [\n 'picklistName' => $picklistName,\n ]);\n\n // There is always 1 result at this point.\n $sfValue = $sfValues->current();\n\n return $sfValue['Metadata']['customValue'];\n } catch (NoResultsException $noResultsException) {\n // Nothing returned.\n\n return [];\n }\n }\n\n /**\n * @inheritdoc\n */\n public function syncProfileRecordTypes(): void\n {\n $objectTypes = [\n 'lead',\n 'account',\n 'contact',\n 'opportunity',\n 'task',\n 'event',\n ];\n\n foreach ($objectTypes as $objectType) {\n try {\n $crmRecordTypes = $this->getRecordTypes(ucfirst($objectType));\n\n foreach ($crmRecordTypes as $crmRecordType) {\n // If the record type is default and not the Master type, set this.\n if ($crmRecordType['default'] && $crmRecordType['recordTypeId'] !== '012000000000000AAA') {\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $crmRecordType['recordTypeId'])\n ->first();\n\n if ($recordType) {\n $this->profile->{$objectType . '_record_type_id'} = $recordType->id;\n }\n }\n }\n } catch (HttpNotFoundException $exception) {\n Log::error('No access to ' . $objectType . ' object, skipping...');\n\n // XXX: should we log this fact somewhere?\n continue;\n }\n }\n\n if ($this->profile->isDirty()) {\n $this->profile->save();\n }\n }\n\n /**\n * Gets business processes.\n */\n public function importBusinessProcesses(): void\n {\n $query = '\n SELECT\n Id, IsActive, Name, TableEnumOrId\n FROM\n BusinessProcess\n WHERE\n TableEnumOrId IN (\\'Lead\\',\\'Opportunity\\')';\n\n try {\n $sfProcesses = $this->queryHandler->query($query);\n\n // Upsert all processes for the team.\n foreach ($sfProcesses as $sfProcess) {\n /** @var BusinessProcess $businessProcess */\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $sfProcess['Id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => $sfProcess['Name'],\n 'type' => $sfProcess['TableEnumOrId'] === 'Lead' ? 'lead' : 'opportunity',\n 'is_selectable' => $sfProcess['IsActive'],\n ]);\n\n $this->importBusinessProcessStages($businessProcess);\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n /**\n * Gets business process stages.\n */\n private function importBusinessProcessStages(BusinessProcess $businessProcess): void\n {\n $query = '\n SELECT\n Metadata\n FROM\n BusinessProcess\n WHERE\n Id = :processId';\n\n try {\n $stages = [];\n $sfProcessStages = $this->queryHandler->metadata($query, [\n 'processId' => $businessProcess->crm_provider_id,\n ]);\n\n // There is always 1 result at this point.\n $sfProcessStage = $sfProcessStages->current();\n\n // Upsert all processes for the team.\n foreach ($sfProcessStage['Metadata']['values'] as $sfProcessStage) {\n $sanitizedName = urldecode($sfProcessStage['valueName']); // Must decode: \"%2C\" becomes \",\" etc.\n\n $stage = $businessProcess->crm->stages()\n // This MUST match on label because this API doesn't use API Name.\n ->where('label', $sanitizedName)\n ->where('type', $businessProcess->type)\n ->where('is_selectable', 1)\n ->first();\n\n if ($stage) {\n $stages[] = $stage->id;\n }\n }\n\n $businessProcess->stages()->sync($stages);\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n /**\n * Gets record types.\n */\n public function importRecordTypes(): void\n {\n $query = '\n SELECT\n Id, IsActive, Name, BusinessProcessId, SobjectType\n FROM\n RecordType';\n\n try {\n $sfRecordTypes = $this->queryHandler->query($query);\n\n // Upsert all record types for the process.\n foreach ($sfRecordTypes as $sfRecordType) {\n $businessProcess = null;\n if ($sfRecordType['BusinessProcessId']) {\n $businessProcess = $this->config->businessProcesses()\n ->where('crm_provider_id', $sfRecordType['BusinessProcessId'])\n ->first();\n }\n\n /** @var RecordType $recordType */\n $recordType = $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $sfRecordType['Id'],\n ], [\n 'team_id' => $this->team->id,\n 'type' => mb_strtolower($sfRecordType['SobjectType']),\n 'name' => $sfRecordType['Name'],\n 'is_selectable' => $sfRecordType['IsActive'],\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n $this->importRecordTypeFieldValues($recordType);\n }\n } catch (NoResultsException $noResultsException) {\n // Do nothing.\n }\n }\n\n /**\n * Import record type - field value mappings. This only works for standard fields.\n */\n private function importRecordTypeFieldValues(RecordType $recordType): void\n {\n try {\n $query = '\n SELECT\n Metadata\n FROM\n RecordType\n WHERE\n Id = :recordTypeId';\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'recordTypeId' => $recordType->crm_provider_id,\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n // Sync field metadata.\n $picklists = $sfField['Metadata']['picklistValues'];\n\n foreach ($picklists as $picklist) {\n $field = $this->config->fields()->where([\n 'type' => Field::TYPE_PICKLIST,\n 'object_type' => $recordType->type,\n 'crm_provider_id' => $picklist['picklist'],\n ])->first();\n\n if ($field) {\n $fieldValues = [];\n\n foreach ($picklist['values'] as $value) {\n // Must decode: \"%2C\" becomes \",\" etc.\n $fieldValue = $field->values()\n ->where('value', urldecode($value['valueName']))\n ->first();\n\n if ($fieldValue) {\n $fieldValues[] = $fieldValue->id;\n }\n }\n\n $recordType->fieldValues()->sync($fieldValues);\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n /**\n * @inheritdoc\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $params = [];\n $missingStage = null;\n if ($types === null) {\n $types = [Stage::TYPE_LEAD, Stage::TYPE_OPPORTUNITY];\n }\n\n foreach ($types as $type) {\n if ($type === Stage::TYPE_LEAD) {\n $query = '\n SELECT\n Id, ApiName, MasterLabel, SortOrder\n FROM\n LeadStatus';\n } else {\n $query = '\n SELECT\n Id, ApiName, MasterLabel, IsActive, SortOrder, DefaultProbability\n FROM\n OpportunityStage';\n }\n\n if ($missingStageName) {\n $escapedStageName = ValueNormalizer::replaceQueryWithStringLiterals($missingStageName);\n\n $query .= ' WHERE ApiName = :stageName';\n\n $params = [\n 'stageName' => $escapedStageName,\n ];\n }\n\n try {\n $sfStages = $this->queryHandler->query($query, $params);\n } catch (NoResultsException $exception) {\n $sfStages = [];\n }\n\n $missingStage = null;\n\n // Upsert all stages for the team.\n foreach ($sfStages as $sfStage) {\n $selectable = true;\n if (array_key_exists('IsActive', $sfStage)) {\n $selectable = $sfStage['IsActive'];\n }\n\n $this->restoreAnyTrashedEntity($this->config->stages(), $sfStage['Id']);\n\n $stage = $this->config->stages()->updateOrCreate([\n 'crm_provider_id' => $sfStage['Id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($sfStage['ApiName'], 0, 50),\n 'label' => mb_strimwidth($sfStage['MasterLabel'], 0, 191),\n 'type' => $type,\n 'sequence' => $sfStage['SortOrder'] ?? 0,\n 'is_selectable' => $selectable,\n 'probability' => $sfStage['DefaultProbability'] ?? null,\n ]);\n\n if ($missingStageName && $missingStageName === $sfStage['ApiName']) {\n $missingStage = $stage;\n }\n }\n\n if ($missingStageName && $missingStage === null) {\n // If they requested a stage that still doesn't exist, it must be inactive so lazy create it.\n $missingStage = $this->config->stages()->create([\n 'crm_provider_id' => Uuid::uuid4(),\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($missingStageName, 0, 50),\n 'label' => mb_strimwidth($missingStageName, 0, 191),\n 'type' => $type,\n 'sequence' => 0,\n 'is_selectable' => 0,\n ]);\n }\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int\n {\n $syncCount = 0;\n $fields = $this->getAllFieldsAsArray('lead');\n if (\\in_array('Id', $fields, true) === false) {\n return $syncCount;\n }\n\n $query = '\n SELECT ' . rtrim(implode(',', $fields), ',') . '\n FROM Lead\n WHERE LastModifiedDate > :since\n ORDER BY LastModifiedDate ASC';\n\n try {\n $sfLeads = $this->queryHandler->query($query, [\n 'since' => $since->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($sfLeads as $sfLead) {\n // Only sync if previously imported.\n if ($this->hasLead($sfLead['Id'])) {\n $this->importLead($sfLead);\n $syncCount++;\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::LEAD);\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLead(string $crmId): ?Lead\n {\n $fields = $this->getAllFieldsAsArray('lead');\n\n $sfLead = $this->getRecord('Lead', $crmId, $fields);\n\n return $this->importLead($sfLead);\n }\n\n private function importLead($crmData): ?Lead\n {\n /** @var ?Stage $stage */\n $stage = null;\n if (isset($crmData['Status'])) {\n // Get the current stage.\n $stage = $this->config\n ->stages()\n ->where('name', $crmData['Status'])\n ->where('type', Stage::TYPE_LEAD)\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_LEAD], $crmData['Status']);\n }\n }\n\n // If we have no way of importing this, just return null :(\n if ($stage === null) {\n return null;\n }\n\n $countryCode = $crmData['CountryCode'] ?? null;\n\n // Salesforce allows custom \"countries\" to be created. Disregard these.\n if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {\n $countryCode = null;\n }\n\n // If we have no country code, try to parse it from the country name.\n if ($countryCode === null && empty($crmData['Country']) !== false) {\n $countryCode = $this->convertCountryNameToCode($crmData['Country']);\n }\n\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($crmData['Phone'] ?? '', 0, 25);\n $parsedNumber = parsePhoneNumber($countryCode, $number);\n\n $mobilePhone = null;\n if (empty($crmData['MobilePhone']) === false) {\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($crmData['MobilePhone'], 0, 25);\n $mobilePhone = phone_e164($countryCode, $number);\n }\n\n $convertedDate = null;\n $convertedAccount = null;\n $convertedOpportunity = null;\n $convertedContact = null;\n\n if ($crmData['IsConverted'] == 'true') {\n $convertedDate = $crmData['ConvertedDate'];\n\n if (empty($crmData['ConvertedAccountId']) === false) {\n $convertedAccount = $this->config\n ->accounts()\n ->where('crm_provider_id', $crmData['ConvertedAccountId'])\n ->first();\n\n if ($convertedAccount === null) {\n try {\n $convertedAccount = $this->syncAccount($crmData['ConvertedAccountId']);\n } catch (HttpNotFoundException $exception) {\n // Probably the user has no permissions to access the converted data.\n }\n }\n }\n\n if (empty($crmData['ConvertedOpportunityId']) === false) {\n $convertedOpportunity = $this->config\n ->opportunities()\n ->where('crm_provider_id', $crmData['ConvertedOpportunityId'])\n ->first();\n\n if ($convertedOpportunity === null) {\n try {\n $convertedOpportunity = $this->syncOpportunity($crmData['ConvertedOpportunityId']);\n } catch (HttpNotFoundException $exception) {\n // Probably the user has no permissions to access the converted data.\n }\n }\n }\n\n if (empty($crmData['ConvertedContactId']) === false) {\n $convertedContact = $this->team\n ->crm\n ->contacts()\n ->where('crm_provider_id', $crmData['ConvertedContactId'])\n ->first();\n\n if ($convertedContact === null) {\n try {\n $convertedContact = $this->syncContact($crmData['ConvertedContactId']);\n } catch (HttpNotFoundException $exception) {\n // Probably the user has no permissions to access the converted data.\n }\n }\n }\n }\n\n if (empty($crmData['Company'])) {\n $company = 'Unknown';\n } else {\n $company = mb_strimwidth($crmData['Company'], 0, 191);\n }\n\n $domain = null;\n if (empty($crmData['Website']) === false) {\n $domain = mb_strimwidth($crmData['Website'], 0, 191);\n $domain = StringUtil::resolveDomain($domain);\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $data = [\n 'team_id' => $this->team->id,\n 'user_id' => $profile?->user_id,\n 'owner_id' => $crmData['OwnerId'] ?? '',\n 'company' => $company,\n 'domain' => $domain,\n 'name' => $crmData['Name'] ? mb_strimwidth($crmData['Name'], 0, 191) : '',\n 'title' => $crmData['Title'] ? mb_strimwidth($crmData['Title'], 0, 128) : null,\n 'email' => $crmData['Email'] ? mb_strimwidth($crmData['Email'], 0, 80) : null,\n 'phone' => $parsedNumber['phone'],\n 'ext' => $parsedNumber['ext'] ?? null,\n 'mobile_phone' => $mobilePhone,\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['Id'],\n modelType: Lead::class,\n fileName: $crmData['Id'],\n avatarText: $crmData['Name']\n ),\n 'stage_id' => $stage->id,\n 'record_type_id' => null,\n 'converted_at' => $convertedDate,\n 'converted_account_id' => $convertedAccount->id ?? null,\n 'converted_opportunity_id' => $convertedOpportunity->id ?? null,\n 'converted_contact_id' => $convertedContact->id ?? null,\n 'country_code' => $countryCode,\n 'remotely_created_at' => $createdDate,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->leads(), $crmData['Id']);\n\n /** @var Lead $lead */\n $lead = $this->config->leads()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n if ($lead->wasChanged('converted_at') && $lead->getConvertedAt() !== null) {\n $this->eventDispatcher->dispatch(new LeadConverted($lead));\n }\n\n $this->handleObjectDeletion($lead, $crmData);\n\n if ($lead->trashed()) {\n return null;\n }\n\n return $lead;\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccounts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n $fields = $this->getAllFieldsAsArray('account');\n\n if (\\in_array('Id', $fields, true) === false) {\n return $syncCount;\n }\n\n $query = '\n SELECT ' . rtrim(implode(',', $fields), ',') . '\n FROM Account\n WHERE LastModifiedDate > :since\n ORDER BY LastModifiedDate ASC';\n\n try {\n $sfAccounts = $this->queryHandler->query($query, [\n 'since' => $since->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($sfAccounts as $sfAccount) {\n // Only sync if previously imported.\n if ($this->hasAccount($sfAccount['Id'])) {\n $this->importAccount($sfAccount);\n $syncCount++;\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::ACCOUNT);\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccount(string $crmId): ?Account\n {\n $fields = $this->getAllFieldsAsArray('account');\n if (! in_array('Id', $fields, true)) {\n $this->logger->info('[Salesforce] Sync account cancelled. Fields are not available.', [\n 'crmId' => $crmId,\n 'userId' => $this->profile->getUserId(),\n ]);\n\n return null;\n }\n\n $sfAccount = $this->getRecord('Account', $crmId, $fields);\n\n return $this->importAccount($sfAccount);\n }\n\n private function importAccount($crmData): Account\n {\n $countryCode = $crmData['BillingCountryCode'] ?? $crmData['ShippingCountryCode'] ?? null;\n\n // Salesforce allows custom \"countries\" to be created. Disregard these.\n if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {\n $countryCode = null;\n }\n\n // If we have no country code, try to parse it from the country names.\n if ($countryCode === null && empty($crmData['BillingCountry']) === false) {\n $countryCode = $this->convertCountryNameToCode($crmData['BillingCountry']);\n }\n\n if ($countryCode === null && empty($crmData['ShippingCountry']) === false) {\n $countryCode = $this->convertCountryNameToCode($crmData['ShippingCountry']);\n }\n\n if (empty($crmData['Phone']) === false) {\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($crmData['Phone'], 0, 25);\n $parsedNumber = parsePhoneNumber($countryCode, $number);\n } else {\n $parsedNumber = [];\n }\n\n $industry = null;\n if (empty($crmData['Industry']) === false) {\n $industry = mb_strimwidth($crmData['Industry'], 0, 40);\n }\n\n $domain = null;\n if (empty($crmData['Website']) === false) {\n $domain = mb_strimwidth($crmData['Website'], 0, 191);\n $domain = StringUtil::resolveDomain($domain);\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $data = [\n 'team_id' => $this->team->id,\n 'user_id' => $profile?->user_id,\n 'owner_id' => $crmData['OwnerId'],\n 'name' => mb_strimwidth($crmData['Name'], 0, 191),\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['Id'],\n modelType: Account::class,\n fileName: $crmData['Id'],\n avatarText: $crmData['Name']\n ),\n 'industry' => $industry,\n 'domain' => $domain,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'ext' => $parsedNumber['ext'] ?? null,\n 'country_code' => $countryCode,\n 'remotely_created_at' => $createdDate,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->accounts(), $crmData['Id']);\n\n /** @var Account $account */\n $account = $this->config->accounts()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n $this->handleObjectDeletion($account, $crmData);\n\n return $account;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOpportunities(array $parameters, ?string $strategy = null): int\n {\n $strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);\n\n $syncCount = 0;\n $logParams = $parameters;\n $parameters['profile'] = $this->profile;\n $logParams['user'] = $this->profile->getUserId();\n\n if (count($strategies) > 1) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Multiple sync strategies used', [\n 'teamId' => $this->team->getUuid(),\n 'params' => $logParams,\n 'strategies_count' => count($strategies),\n ]);\n }\n\n foreach ($strategies as $syncStrategy) {\n $name = $syncStrategy->getStrategyName();\n\n try {\n $sfOpportunities = $syncStrategy->fetchOpportunities($parameters);\n $totalRecords = $sfOpportunities->count();\n\n foreach ($sfOpportunities as $sfOpportunity) {\n $this->importOpportunity($sfOpportunity);\n $syncCount++;\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n $this->logger->warning('[' . $this->getDisplayName() . '] No opportunities found', [\n 'teamId' => $this->team->getUuid(),\n 'name' => $name,\n 'params' => $logParams,\n 'reason' => $noResultsException->getMessage(),\n ]);\n } catch (CrmException $crmException) {\n // Nothing to sync.\n $this->logger->warning('[' . $this->getDisplayName() . '] Opportunity sync failed', [\n 'teamId' => $this->team->getUuid(),\n 'name' => $name,\n 'params' => $logParams,\n 'reason' => $crmException->getMessage(),\n ]);\n }\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::OPPORTUNITY, ['params' => $logParams]);\n\n // debug to see how if count of opportunities reaches 1000\n if ($syncCount >= 1000) {\n $this->logger->info(\n '[' . $this->getDisplayName() . '] Sync Opportunities - count warning',\n [\n 'team_id' => $this->team->getId(),\n 'params' => $logParams,\n 'count' => $syncCount,\n 'strategies_count' => count($strategies),\n 'total_records' => $totalRecords ?? null,\n ]\n );\n }\n\n return $syncCount;\n }\n\n\n /**\n * @inheritdoc\n */\n public function syncOpportunity(string $crmId): ?Opportunity\n {\n $strategy = $this->opportunitySyncStrategyResolver->resolve(\n $this->config,\n OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY\n );\n\n $parameters = [\n 'profile' => $this->profile,\n 'crm_id' => $crmId,\n ];\n\n try {\n $sfOpportunity = $strategy->fetchOpportunities($parameters);\n } catch (HttpNotFoundException $e) {\n $this->logger->info('[' . $this->getDisplayName() . '] Opportunity not found', [\n 'teamId' => $this->team->id_string,\n 'crmId' => $crmId,\n ]);\n\n return null;\n } catch (CrmException $crmException) {\n $this->logger->info('[' . $this->getDisplayName() . '] Opportunity sync failed', [\n 'teamId' => $this->team->id_string,\n 'crmId' => $crmId,\n 'exception' => $crmException->getMessage(),\n ]);\n\n return null;\n }\n\n if ($sfOpportunity instanceof ArrayIterator) {\n return $this->importOpportunity($sfOpportunity->getItems());\n }\n\n return $this->importOpportunity($sfOpportunity);\n }\n\n /**\n * @throws HttpNotFoundException\n */\n private function importOpportunity($crmData): ?Opportunity\n {\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $account = null;\n if (empty($crmData['AccountId']) === false) {\n /** @var ?Account $account */\n $account = $this->config->accounts()\n ->where('crm_provider_id', (string) $crmData['AccountId'])\n ->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmData['AccountId']);\n }\n }\n\n $userId = $profile?->getUserId() ?? $account?->getUserId();\n if ($userId === null) {\n $this->logger->error('[Salesforce] | Skip import, no user_id found', [\n 'id' => $crmData['Id'],\n ]);\n\n return null;\n }\n\n /** @var ?Stage $stage */\n $stage = null;\n if (isset($crmData['StageName'])) {\n $stage = $this->config\n ->stages()\n ->where('name', $crmData['StageName'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->orderBy('is_selectable', 'DESC')\n ->orderBy('id')\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $crmData['StageName']);\n }\n }\n\n $recordType = null;\n if (empty($crmData['RecordTypeId']) === false) {\n /** @var ?RecordType $recordType */\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $crmData['RecordTypeId'])\n ->first();\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $closeDate = null;\n if (empty($crmData['CloseDate']) === false) {\n $closeDate = Carbon::parse($crmData['CloseDate'])->format('Y-m-d');\n }\n\n $valueFieldName = 'Amount';\n if ($this->config->opportunity_value_field_id) {\n $valueFieldName = $this->config->opportunityValueField->crm_provider_id;\n }\n\n $data = [\n 'team_id' => $this->team->id,\n 'account_id' => $account->id ?? null,\n 'user_id' => $userId,\n 'owner_id' => $crmData['OwnerId'] ?? null,\n 'name' => mb_strimwidth($crmData['Name'] ?? '', 0, 128),\n 'value' => $crmData[$valueFieldName],\n 'currency_code' => CurrencyFormatter::formatCode($crmData['CurrencyIsoCode'] ?? null),\n 'close_date' => $closeDate,\n 'is_closed' => $crmData['IsClosed'],\n 'is_won' => $crmData['IsWon'],\n 'stage_id' => $stage?->id ?? null,\n 'record_type_id' => $recordType->id ?? null,\n 'remotely_created_at' => $createdDate,\n 'probability' => $crmData['Probability'] ?? null,\n 'forecast_category' => $crmData['ForecastCategoryName'] ?? null,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->opportunities(), $crmData['Id']);\n\n // Do not allow locked DB tables & other errors\n // to interrupt the process of reverting the trashed opportunities\n try {\n /** @var Opportunity $opportunity */\n $opportunity = $this->config->opportunities()\n ->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n // import external fields into crm_field_data if present\n $crmFields = $this->getOpportunitySyncableFields();\n\n $this->importOpportunityCrmFieldData($crmData, $crmFields, $opportunity->id);\n\n $this->handleObjectDeletion($opportunity, $crmData);\n\n if ($opportunity->trashed()) {\n return null;\n }\n\n if ($opportunity->wasRecentlyCreated) {\n MatchActivitiesToNewOpportunity::dispatch($opportunity->getId());\n }\n\n return $opportunity;\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n $this->logger->error('[Salesforce] importOpportunity failure.', [\n 'crm_provider_id' => $crmData['Id'],\n 'team_id' => $this->team->id,\n 'exception' => $exception->getMessage(),\n ]);\n\n $this->handleEntityDeletionByProviderId($this->config->opportunities(), $crmData);\n }\n\n return null;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContacts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n $fields = $this->getAllFieldsAsArray('contact');\n if (\\in_array('Id', $fields, true) === false) {\n return $syncCount;\n }\n\n $query = '\n SELECT ' . rtrim(implode(',', $fields), ',') . '\n FROM Contact\n WHERE LastModifiedDate > :since\n ORDER BY LastModifiedDate ASC';\n\n try {\n $sfContacts = $this->queryHandler->query($query, [\n 'since' => $since->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($sfContacts as $sfContact) {\n // Only sync if previously imported.\n if ($this->hasContact($sfContact['Id'])) {\n $this->importContact($sfContact);\n $syncCount++;\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::CONTACT);\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContact(string $crmId): ?Contact\n {\n $fields = $this->getAllFieldsAsArray('contact');\n if (! in_array('Id', $fields, true)) {\n $this->logger->info('[Salesforce] Sync contact cancelled. Fields are not available.', [\n 'crmId' => $crmId,\n 'userId' => $this->profile->getUserId(),\n ]);\n\n return null;\n }\n\n $sfContact = $this->getRecord('Contact', $crmId, $fields);\n\n return $this->importContact($sfContact);\n }\n\n private function importContact($crmData): ?Contact\n {\n $account = null;\n // Contacts may not have accounts...\n if (isset($crmData['AccountId'])) {\n $account = $this->config->accounts()\n ->where('crm_provider_id', (string) $crmData['AccountId'])\n ->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmData['AccountId']);\n }\n }\n\n $countryCode = $crmData['MailingCountryCode'] ?? null;\n\n // Salesforce allows custom \"countries\" to be created. Disregard these.\n if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {\n $countryCode = null;\n }\n\n // If we have no country code, try to parse it from the country name.\n if ($countryCode === null && empty($crmData['MailingCountry']) === false) {\n $countryCode = $this->convertCountryNameToCode($crmData['MailingCountry']);\n\n if ($countryCode === null && $account) {\n $countryCode = $account->country_code;\n }\n }\n\n $ext = null;\n $parsedNumber = [];\n if (empty($crmData['Phone']) === false) {\n $number = Str::limit($crmData['Phone'], 25, '');\n $parsedNumber = parsePhoneNumber($countryCode, $number);\n\n if (empty($parsedNumber['ext']) === false) {\n $ext = Str::limit($parsedNumber['ext'], 10, '');\n }\n }\n\n $mobileNumber = null;\n if (empty($crmData['MobilePhone']) === false) {\n $mobileNumber = Str::limit(phone_e164($countryCode, $crmData['MobilePhone']), 25, '');\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $data = [\n 'team_id' => $this->team->id,\n 'account_id' => $account->id ?? null,\n 'user_id' => $profile?->user_id,\n 'owner_id' => $crmData['OwnerId'] ?? null,\n 'name' => ($crmData['Name'] ?? null) !== null ? mb_strimwidth($crmData['Name'], 0, 100) : '',\n 'title' => ($crmData['Title'] ?? null) !== null ? mb_strimwidth($crmData['Title'], 0, 128) : null,\n 'email' => ($crmData['Email'] ?? null) !== null ? mb_strimwidth($crmData['Email'], 0, 191) : null,\n 'country_code' => $countryCode,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'ext' => $ext,\n 'mobile_phone' => $mobileNumber,\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['Id'],\n modelType: Contact::class,\n fileName: $crmData['Id'],\n avatarText: $crmData['Name']\n ),\n 'remotely_created_at' => $createdDate,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->contacts(), $crmData['Id']);\n\n /** @var Contact $contact */\n $contact = $this->config->contacts()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n $this->handleObjectDeletion($contact, $crmData);\n\n if ($contact->trashed()) {\n return null;\n }\n\n return $contact;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n $fields = [\n 'InstanceName',\n 'OrganizationType',\n 'IsSandbox',\n ];\n\n $orgValues = $this->getRecord('Organization', $this->config->crm_provider_id, $fields);\n\n $edition = null;\n switch ($orgValues['OrganizationType']) {\n case 'Developer Edition':\n $edition = Configuration::EDITION_DEVELOPER;\n\n break;\n\n case 'Professional Edition':\n $edition = Configuration::EDITION_PROFESSIONAL;\n\n break;\n\n case 'Enterprise Edition':\n $edition = Configuration::EDITION_ENTERPRISE;\n\n break;\n }\n\n $this->config->edition = $edition;\n $this->config->instance = $orgValues['InstanceName'];\n\n // XXX: How can this state be possible?\n if ($this->config->version === null) {\n $this->config->version = Client::MIN_API_VERSION;\n }\n\n $installedVersion = $this->getInstalledAppVersion();\n if ($installedVersion !== null) {\n $installedVersion = (string) $this->getInstalledAppVersion();\n }\n\n $this->config->installed_app_version = $installedVersion;\n\n $this->config->save();\n }\n\n public function getInstalledAppVersion(): ?string\n {\n try {\n $query = '\n SELECT\n SubscriberPackageVersion.MajorVersion,\n SubscriberPackageVersion.MinorVersion,\n SubscriberPackageVersion.PatchVersion,\n SubscriberPackageVersion.BuildNumber\n FROM\n InstalledSubscriberPackage\n WHERE\n SubscriberPackageId = :packageId\n ';\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'packageId' => self::INSTALLED_PACKAGE_ID,\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n // Grab version number.\n $version = $sfField['SubscriberPackageVersion']['MajorVersion'] .\n $sfField['SubscriberPackageVersion']['MinorVersion'] .\n $sfField['SubscriberPackageVersion']['PatchVersion'] .\n $sfField['SubscriberPackageVersion']['BuildNumber'];\n } catch (\\Exception) {\n $version = null;\n }\n\n return $version;\n }\n\n /**\n * Store transcripts as note.\n *\n * @throws \\Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // For SF we also check if Log Notes is enabled.\n if ($this->profile->log_notes === Profile::LOG_NOTE_NONE) {\n return;\n }\n\n if ($activity->opportunity_id && $activity->prospect === null) {\n return;\n }\n\n try {\n $transcriptionData = $this->generateTranscription($activity);\n\n $noteMaxLength = $this->profile->log_notes === Profile::LOG_NOTE_ENHANCED\n ? self::ENHANCED_NOTE_MAX_LENGTH\n : self::CLASSIC_NOTE_MAX_LENGTH;\n\n $title = 'Transcript for ';\n $title .= $activity->title ?? $activity->activity_title;\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $body = mb_strimwidth($transcriptionData, 0, $noteMaxLength);\n\n if ($activity->opportunity_id) {\n $objectId = $activity->opportunity->crm_provider_id;\n } else {\n $objectId = $activity->prospect->crm_provider_id;\n }\n\n $noteId = $this->saveNote($title, $body, $objectId);\n\n // Store crm logged id in transcription.\n $transcription = $activity->getTranscription();\n $transcription->crm_activity_id = $noteId;\n $transcription->save();\n } catch (\\Exception $e) {\n \\Sentry::captureException($e);\n }\n }\n\n public function saveNote(string $title, string $body, string $objectId, ?NoteObject $noteObject = null): ?string\n {\n $noteId = null;\n\n try {\n if ($this->profile->log_notes === Profile::LOG_NOTE_ENHANCED) {\n $noteId = $this->buildEnhancedNote($title, $body, $objectId);\n } else {\n $noteId = $this->buildClassicNote($title, $body, $objectId);\n }\n } catch (HttpNotFoundException $exception) {\n // The profile not having access to create Enhanced Notes. Set their preference to Classic.\n if ($this->profile->log_notes === Profile::LOG_NOTE_ENHANCED) {\n $this->profile->update([\n 'log_notes' => Profile::LOG_NOTE_CLASSIC,\n ]);\n }\n }\n\n return $noteId;\n }\n\n /**\n * This is using the \"Enhanced\" Notes feature, NOT the \"Notes & Attachments\" feature being deprecated.\n *\n * @url https://salesforce.stackexchange.com/questions/104408/how-can-i-create-an-account-note-or-contact-note-via-api-that-is-visible-in-sale\n */\n private function buildEnhancedNote(string $title, string $body, string $objectId): string\n {\n // Decode stored entities, escape HTML (without quoting), then convert line breaks for Salesforce formatting\n $decodedBody = html_entity_decode($body, ENT_QUOTES | ENT_HTML5);\n $sanitizedBody = htmlspecialchars($decodedBody, ENT_NOQUOTES, 'UTF-8', false);\n $content = nl2br($sanitizedBody, false);\n $note = [\n 'OwnerId' => $this->profile->crm_provider_id,\n 'Title' => $title,\n 'Content' => base64_encode($content),\n ];\n\n $noteId = $this->createRecord('ContentNote', $note);\n\n $link = [\n 'ContentDocumentId' => $noteId,\n 'LinkedEntityId' => $objectId,\n 'ShareType' => 'I',\n ];\n\n $this->createRecord('ContentDocumentLink', $link);\n\n return $noteId;\n }\n\n private function buildClassicNote(string $title, string $body, string $objectId): string\n {\n if (in_array($this->parseObjectType($objectId), [Field::OBJECT_TASK, Field::OBJECT_EVENT])) {\n $this->logger->info('[Salesforce] Summary not sent', [\n 'profile_id' => $this->profile->id,\n 'objectId' => $objectId,\n 'reason' => 'Classical Note does not support Task/Event relation',\n ]);\n\n return '';\n }\n\n $titleTrimmed = null;\n\n if (mb_strlen($title) > 80) {\n $titleTrimmed = substr($title, 0, 77) . '...';\n }\n $payload = [\n 'OwnerId' => $this->profile->crm_provider_id,\n 'IsPrivate' => false,\n 'Title' => $titleTrimmed ?? $title,\n 'Body' => $titleTrimmed ? $title . PHP_EOL . $body : $body,\n 'ParentId' => $objectId,\n ];\n\n return $this->createRecord('Note', $payload);\n }\n\n /**\n * @inheritdoc\n */\n public function find(string $name, array $scopes): array\n {\n if ($this->profile === null) {\n return [];\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $limitValues = ['limit' => $this->limit, 'offset' => $this->offset];\n $sosl = $queryBuilder->buildFindQuery($name, $scopes, $limitValues);\n\n $this->logger->info('[Salesforce] Find prospects', [\n 'profile_id' => $this->profile->id,\n 'sosl_query' => $sosl,\n 'search_string' => $name,\n 'scopes' => $scopes,\n ]);\n\n $data = Cache::remember($this->profile->id . $sosl, self::CACHE_TTL, function () use ($sosl) {\n $data = [];\n\n try {\n // Hit remote API.\n $objects = $this->queryHandler->search($sosl);\n\n // Build mapped list.\n foreach ($objects as $object) {\n $type = strtolower($object['attributes']['type']);\n\n $record = [\n 'crmId' => $object['Id'],\n 'name' => $object['Name'],\n 'prospectType' => $type,\n 'phoneNumbers' => [],\n 'crmUrl' => $this->generateProviderUrl($object['Id'], $type),\n ];\n\n switch ($type) {\n case 'lead':\n if (empty($object['Company']) === false) {\n $record['organization'] = $object['Company'];\n }\n\n if (empty($object['Title']) === false) {\n $record['title'] = $object['Title'];\n }\n\n $stage = $this->config->stages()\n ->where('type', Stage::TYPE_LEAD)\n ->where('name', $object['Status'])\n ->first();\n\n // Lazy create the stage.\n if ($stage === null) {\n $stage = $this->importStages([Stage::TYPE_LEAD], $object['Status']);\n }\n\n if ($stage) {\n $record += [\n 'stage' => [\n 'id' => $stage->id_string,\n 'name' => $stage->name,\n ],\n ];\n }\n\n if (empty($object['RecordTypeId']) === false) {\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $object['RecordTypeId'])\n ->first();\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n }\n\n break;\n\n case 'account':\n if (empty($object['Industry']) === false) {\n $record['industry'] = $object['Industry'];\n $record['detailsLine'] = $object['Industry'];\n }\n if (! empty($object['PersonEmail'])) {\n $record['detailsLine'] = $object['PersonEmail'];\n }\n\n break;\n\n case 'contact':\n // For contacts, we should try and fetch their account name too.\n if ($object['AccountId']) {\n // Cheaper to get this locally.\n $account = $this->config->accounts()\n ->where('crm_provider_id', $object['AccountId'])\n ->first(['name']);\n\n if ($account) {\n $record['organization'] = $account->name;\n }\n }\n\n if (! empty($object['IsPersonAccount']) && $object['Email']) {\n $record['detailsLine'] = $object['Email'];\n } else {\n if (empty($object['Title']) === false) {\n $record['title'] = $object['Title'];\n }\n }\n\n break;\n }\n\n // Add phone numbers to record.\n if (empty($object['Phone']) === false && $object['Phone']) {\n $record['phoneNumbers'][] = [\n 'number' => $object['Phone'],\n 'nationalFormat' => phone_national($this->profile->user->country_code, $object['Phone']),\n 'type' => 'phone',\n ];\n }\n\n if (empty($object['MobilePhone']) === false && $object['MobilePhone']) {\n $record['phoneNumbers'][] = [\n 'number' => $object['MobilePhone'],\n 'nationalFormat' => phone_national(\n $this->profile->user->country_code,\n $object['MobilePhone']\n ),\n 'type' => 'mobile',\n ];\n }\n\n $data[] = $record;\n }\n } catch (NoResultsException $e) {\n $data = [];\n }\n\n return $data;\n });\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n\n if ($crmAccountId === null) {\n return $data;\n }\n\n if ($userId) {\n $profileRepository = app(ProfileRepository::class);\n $profile = $profileRepository->findProfileByUserId($this->config, $userId);\n\n $ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;\n }\n\n try {\n // Perhaps their profile has no opportunity permissions.\n if ($this->profile === null || $this->profile->opportunity_fields === null) {\n return $data;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $query = $queryBuilder->buildFindOpportunitiesQuery();\n\n $objects = $this->queryHandler->query($query, ['accountId' => $crmAccountId]);\n\n foreach ($objects as $object) {\n $record = [\n 'crmId' => $object['Id'],\n 'name' => $object['Name'],\n 'won' => $object['IsWon'],\n 'closed' => $object['IsClosed'],\n ];\n\n $valueFieldName = 'Amount';\n if ($this->config->opportunity_value_field_id) {\n $valueFieldName = $this->config->opportunityValueField->crm_provider_id;\n }\n\n if (empty($object[$valueFieldName]) === false) {\n $currency = $object['CurrencyIsoCode'] ?? $this->config->default_currency;\n $value = formatCurrency($object[$valueFieldName], $currency);\n\n $record += [\n 'value' => $value,\n ];\n }\n\n $stage = $this->config->stages()\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->where('name', $object['StageName'])\n ->first();\n\n // Lazy create the stage.\n if ($stage === null) {\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $object['StageName']);\n }\n\n $record += [\n 'stage' => [\n 'id' => $stage->id_string,\n 'name' => $stage->name,\n ],\n ];\n\n if (empty($object['RecordTypeId']) === false) {\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $object['RecordTypeId'])\n ->first();\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n }\n\n if ($ownerId && isset($object['OwnerId']) && $object['OwnerId'] === $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n } catch (NoResultsException $e) {\n return $data;\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n public function getContactRolesFromCrm(?Carbon $since = null): array\n {\n $roles = [];\n\n if ($this->profile === null) {\n return $roles;\n }\n\n $queryBuilder = app(QueryBuilder::class, ['profile' => $this->profile]);\n\n $query = $queryBuilder->buildGetContactRolesQuery($since);\n\n try {\n $objects = $this->queryHandler->query($query);\n\n foreach ($objects as $object) {\n $roles[] = [\n 'id' => $object['Id'],\n 'contactId' => $object['ContactId'],\n 'opportunityId' => $object['OpportunityId'],\n 'ownerId' => $object['Opportunity']['OwnerId'] ?? null,\n 'isPrimary' => $object['IsPrimary'],\n 'role' => $object['Role'],\n ];\n }\n } catch (NoResultsException) {\n // Just return an empty array.\n $this->logger->info('[Salesforce] No contact roles found', [\n 'since' => $since?->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n }\n\n return $roles;\n }\n\n public function syncContactRoles(Carbon $since): int\n {\n $contactRoleRepository = app(ContactRoleRepository::class);\n\n $crmContactRoles = $this->getContactRolesFromCrm(since: $since);\n $syncCount = 0;\n $contactRoles = [];\n\n foreach ($crmContactRoles as $crmContactRole) {\n $contactRoles[] = $this->importContactRole($crmContactRole);\n $syncCount++;\n }\n\n $contactRoleRepository->saveContactRoles($contactRoles);\n\n $this->syncRemotelyDeletedContactRoles();\n\n return $syncCount;\n }\n\n private function importContactRole(array $contactRole): array\n {\n $contact = $this->config->contacts()\n ->where('crm_provider_id', $contactRole['contactId'])\n ->first();\n\n if ($contact === null) {\n $contact = $this->syncContact($contactRole['contactId']);\n }\n\n $opportunity = $this->config->opportunities()\n ->where('crm_provider_id', $contactRole['opportunityId'])\n ->first();\n\n if ($opportunity === null) {\n $opportunity = $this->syncOpportunity($contactRole['opportunityId']);\n }\n\n $role = null;\n if (! empty($contactRole['role'])) {\n $role = mb_strimwidth($contactRole['role'], 0, 191);\n }\n\n return [\n 'crm_configuration_id' => $this->config->getId(),\n 'contact_id' => $contact->getId(),\n 'crm_provider_id' => $contactRole['id'],\n 'subject_type' => ContactRole::SUBJECT_TYPE_OPPORTUNITY,\n 'subject_id' => $opportunity->getId(),\n 'is_primary' => $contactRole['isPrimary'],\n 'role' => $role,\n ];\n }\n\n protected function syncRemotelyDeletedContactRoles(): bool\n {\n try {\n $deletedRemotely = $this->queryHandler->queryDeleted('OpportunityContactRole');\n } catch (NoResultsException $e) {\n return false;\n }\n\n $deletedOpportunities = $deletedRemotely->getResults();\n $deletedIds = array_column($deletedOpportunities, 'id');\n\n $contactRoleRepository = app(ContactRoleRepository::class);\n\n foreach (array_chunk($deletedIds, self::HARD_DELETE_CHUNK) as $chunk) {\n $contactRoleRepository->deleteContactRoles($chunk);\n\n $this->logger->info('[' . $this->getDisplayName() . '] Remotely deleted opportunities synced', [\n 'teamId' => $this->team->id_string,\n 'remotelyDeletedOpportunities' => $chunk,\n 'count' => count($chunk),\n ]);\n }\n\n return true;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = $objects = [];\n\n $hasWho = \\in_array($objectType, ['lead', 'contact']);\n $playbook = $this->getPlaybook($this->profile->user);\n $fields = array_merge(\n $this->profile->getFieldsAsArray(parent::OBJECT_TASK),\n $playbook && $playbook->activityField ? [$playbook->activityField->crm_provider_id] : []\n );\n\n // Query should default to any open call for that user.\n $query = '\n SELECT ' . implode(',', array_unique($fields)) . '\n FROM Task\n WHERE OwnerId = :ownerId\n AND IsArchived = false\n AND IsDeleted = false\n AND IsClosed = false\n AND (';\n\n if ($objectType === 'account') {\n // This covers tasks tied to a related contact or opportunity too.\n $query .= '\n AccountId = :accountId';\n }\n\n if ($hasWho) {\n $query .= '\n WhoId = :whoId';\n\n // If we are also going to check on a specific opportunity, set that up.\n if ($opportunityId) {\n $query .= ' OR WhatId = :whatId';\n }\n }\n\n $query .= ' ) ORDER BY LastModifiedDate DESC';\n\n try {\n $objects = $this->queryHandler->query($query, [\n 'ownerId' => $this->profile->crm_provider_id,\n 'whoId' => $objectId,\n 'whatId' => $opportunityId,\n 'accountId' => $objectId,\n ]);\n } catch (NoResultsException $e) {\n return $data;\n } finally {\n $this->logger->debug(sprintf('[Salesforce] Found %s tasks for query \"%s\"', count($objects), $query));\n }\n\n foreach ($objects as $object) {\n $dueDate = $object['ActivityDate'] ? Carbon::parse($object['ActivityDate'])->toIso8601String() : null;\n $data[] = [\n 'crmId' => $object['Id'],\n 'subject' => $object['Subject'],\n 'due' => $dueDate,\n 'type' => $object[$playbook->activityField->crm_provider_id],\n ];\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getEvents(string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = $objects = [];\n $user = $this->profile?->user;\n if ($this->profile === null || $user === null) {\n return $data;\n }\n\n $hasWho = \\in_array($objectType, ['lead', 'contact']);\n $playbook = $this->getPlaybook($user);\n $fields = array_merge(\n $this->profile->getFieldsAsArray(parent::OBJECT_EVENT),\n $playbook && $playbook->activityField ? [$playbook->activityField->crm_provider_id] : []\n );\n\n // Query should default to any event starting in the last week and ending up until today owned by the user.\n $query = '\n SELECT ' . implode(',', array_unique($fields)) . '\n FROM Event\n WHERE OwnerId = :ownerId\n AND IsArchived = false\n AND IsAllDayEvent = false\n AND StartDateTime >= LAST_N_DAYS:7\n AND EndDateTime <= TODAY\n AND (';\n\n if ($objectType === 'account') {\n // This covers events tied to a related contact or opportunity too.\n $query .= '\n AccountId = :accountId';\n }\n\n if ($hasWho) {\n $query .= '\n WhoId = :whoId';\n\n // If we are also going to check on a specific opportunity, set that up.\n if ($opportunityId) {\n $query .= ' OR WhatId = :whatId';\n }\n }\n\n $query .= ' ) ORDER BY LastModifiedDate DESC';\n\n try {\n $objects = $this->queryHandler->query($query, [\n 'ownerId' => $this->profile->crm_provider_id,\n 'whoId' => $objectId,\n 'whatId' => $opportunityId,\n 'accountId' => $objectId,\n ]);\n } catch (NoResultsException $e) {\n return $data;\n } finally {\n $this->logger->debug(sprintf('[Salesforce] Found %s tasks for query \"%s\"', count($objects), $query));\n }\n\n foreach ($objects as $object) {\n $dueDate = $object['StartDateTime'] ? Carbon::parse($object['StartDateTime'])->toIso8601String() : null;\n\n $data[] = [\n 'crmId' => $object['Id'],\n 'subject' => $object['Subject'],\n 'due' => $dueDate,\n 'type' => $object[$playbook->activityField->crm_provider_id],\n ];\n }\n\n return $data;\n }\n\n /**\n * Try to find CRM Objects using email address\n *\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchExactlyByEmail(string $email, ?int $userId = null): ?array\n {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $sosl = $queryBuilder->buildMatchByQuery($email, Field::TYPE_EMAIL);\n if ($sosl === null) {\n return null;\n }\n\n try {\n $objects = $this->queryHandler->search($sosl);\n $objects = $this->queryHandler->prioritiseResults(\n $objects,\n $email,\n QueryHandler::PRIORITISE_EMAIL\n );\n\n $data = $this->convertCrmData($objects, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (NoResultsException $e) {\n // Try the account next.\n if ($this->profile->account_fields === null) {\n return null;\n }\n }\n\n return null;\n }\n\n public function getDomain(string $email): ?string\n {\n // SF improved search - strip the domain extension, min domain name length 4\n return $this->getCompanyNameFromEmail(email: $email, minNameLength: 4);\n }\n\n /**\n * Try to find CRM objects using domain name of the email address\n *\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByDomain(string $domain, ?int $userId = null): ?array\n {\n $companyName = $domain;\n\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $sosl = $queryBuilder->buildMatchByDomainQuery($companyName);\n\n try {\n $objects = $this->queryHandler->search($sosl);\n\n $data = $this->convertCrmData($objects, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (NoResultsException) {\n return null;\n }\n }\n\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n // Don't bother looking up numbers that are masked.\n if (str_contains($phone, '**')) {\n return null;\n }\n\n if ($this->isPhoneNumberOfTeamMember($phone)) {\n return null;\n }\n\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $phoneNational = phone_national(null, $phone) ?? '';\n $possiblePhoneFormats = collect([\n preg_replace('/\\D/', '', ltrim($phone, '0+')),\n preg_replace('/\\D/', '', $phoneNational),\n formatDashPhoneNumber($phone),\n $phoneNational,\n ])\n ->filter() // Removes null and empty strings\n ->unique()\n ->values();\n\n foreach ($possiblePhoneFormats as $phone) {\n $sosl = $queryBuilder->buildMatchByQuery($phone, Field::TYPE_PHONE);\n if ($sosl === null) {\n continue;\n }\n\n try {\n $objects = $this->queryHandler->search($sosl);\n $objects = $this->queryHandler->prioritiseResults(\n $objects,\n $phone,\n QueryHandler::PRIORITISE_PHONE\n );\n\n return $this->convertCrmData($objects, $userId);\n } catch (NoResultsException) {\n continue;\n }\n }\n\n return null;\n }\n\n private function isPhoneNumberOfTeamMember(string $phone): bool\n {\n $teamRepository = app(TeamRepository::class);\n $user = $teamRepository->findTeamMemberByPhone($this->team, $phone);\n\n if ($user instanceof User) {\n return true;\n }\n\n return false;\n }\n\n protected function getCacheKey(string $object, ?int $userId = null): ?string\n {\n $key = $this->profile->id . $object;\n $keySuffix = $this->getOwnerKeySuffix($userId);\n\n return $key . $keySuffix;\n }\n\n private function getOwnerKeySuffix(?int $userId = null): string\n {\n return $userId === null ? '' : (string) $userId;\n }\n\n /** Determine the CRM Objects which represent the call activity. */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n if ($this->profile === null) {\n return null;\n }\n\n $cacheKey = $this->getCacheKey($name, $userId);\n\n $result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $sosl = $queryBuilder->buildMatchByQuery($name, 'name');\n if ($sosl === null) {\n return false;\n }\n\n try {\n $objects = $this->queryHandler->search($sosl);\n } catch (NoResultsException $e) {\n return false;\n }\n\n $objects = $this->queryHandler->prioritiseResults(\n $objects,\n $name,\n QueryHandler::PRIORITISE_NAME\n );\n\n $data = $this->convertCrmData($objects, $userId);\n\n return (! empty(array_filter($data))) ? $data : false;\n });\n\n return is_array($result) ? $result : null;\n }\n\n /**\n * @return array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n protected function convertCrmData(QueryIterator $objects, ?int $userId = null): array\n {\n $lead = null;\n $contact = null;\n $opportunity = null;\n $account = null;\n $stage = null;\n $countryCode = null;\n\n if ($objects->count() > 0) {\n $object = $objects->current();\n\n if ($object['attributes']['type'] === 'Lead') {\n $lead = $this->importLead($object);\n\n // Lead might not be imported if the Stage is null for example.\n if ($lead) {\n $countryCode = $lead->country_code;\n $stage = $lead->stage;\n }\n } else {\n if ($object['attributes']['type'] === 'Contact') {\n $contact = $this->importContact($object);\n $account = $contact->account;\n } else {\n $account = $this->importAccount($object);\n }\n\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account) {\n $countryCode = $account->country_code;\n }\n\n try {\n $sfOpportunities = $this->findOpportunities(\n $account?->getCrmProviderId(),\n $contact?->getCrmProviderId(),\n $userId\n );\n\n // Take the first opportunity, which will be ordered as priority based on their settings.\n if (! empty($sfOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($sfOpportunities[0]['crmId']);\n $stage = $opportunity?->stage;\n }\n } catch (Exception) {\n // Nothing to see here.\n }\n }\n }\n\n return [\n $lead,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n /**\n * @inheritdoc\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n if ($stage->type === Stage::TYPE_LEAD) {\n $objectType = 'Lead';\n $objectId = $crmObject->crm_provider_id;\n $objectStageType = 'Status';\n } else {\n $objectType = 'Opportunity';\n $objectId = $crmObject->crm_provider_id;\n $objectStageType = 'StageName';\n }\n\n $headers = [];\n if ($this->config->trigger_assignment_rules === false) {\n // @see: https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/headers_autoassign.htm\n $headers = [\n 'Sforce-Auto-Assign' => 'false',\n ];\n }\n\n $this->updateRecord($objectType, $objectId, [$objectStageType => $stage->name], $headers);\n }\n\n public function parseObjectType(string $objectId): string\n {\n if (Str::startsWith($objectId, '001')) {\n return 'account';\n }\n\n if (Str::startsWith($objectId, '003')) {\n return 'contact';\n }\n\n if (Str::startsWith($objectId, '00Q')) {\n return 'lead';\n }\n\n if (Str::startsWith($objectId, '006')) {\n return 'opportunity';\n }\n\n if (Str::startsWith($objectId, '00U')) {\n return 'event';\n }\n\n if (Str::startsWith($objectId, '00T')) {\n return 'task';\n }\n\n throw new \\InvalidArgumentException('Unsupported Object Type');\n }\n\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, ['profile' => $this->profile]);\n $query = $queryBuilder->buildGetUsersQuery($userToSearch);\n\n try {\n $salesforceUsers = $this->queryHandler->query($query, [\n 'active' => true,\n ]);\n } catch (NoResultsException $e) {\n $this->logger->info('[Salesforce] Sync Profiles. No users found', [\n 'query' => $query,\n 'error' => $e->getMessage(),\n ]);\n\n return null;\n }\n\n $teamRepository = app(TeamRepository::class);\n $customRules = $this->getCustomProfileRules($teamRepository);\n\n foreach ($salesforceUsers as $crmUser) {\n if ($crmUser['Email'] === null) {\n continue;\n }\n\n if (! $this->customProfileValidation($crmUser, $customRules)) {\n continue;\n }\n\n $user = $teamRepository->findActiveTeamMemberByEmail($this->team, $crmUser['Email']);\n\n if (! $user instanceof User) {\n continue;\n }\n\n $edition = $crmUser['UserPreferencesLightningExperiencePreferred']\n ? Profile::EDITION_LIGHTNING\n : Profile::EDITION_CLASSIC;\n\n $profileRepository = app(ProfileRepository::class);\n $profile = $profileRepository->updateOrCreateProfile(\n $user,\n [\n 'crm_configuration_id' => $this->config->getId(),\n 'crm_provider_id' => $crmUser['Id'],\n ],\n [\n 'user_id' => $user->getId(),\n 'edition' => $edition,\n 'has_external_cti' => ! empty($crmUser['CallCenterId']),\n 'crm_profile_id' => $crmUser['ProfileId'],\n ]\n );\n\n if ($userToSearch instanceof User && $userToSearch->getId() === $user->getId()) {\n return $profile;\n }\n }\n\n // Clean up inactive profiles\n try {\n $this->archiveInactiveProfiles();\n } catch (\\Exception $e) {\n $this->logger->warning('[Salesforce] Profile archiving failed', [\n 'teamId' => $this->team->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $url = null;\n\n // For Salesforce it's easy, we just point every object to the apex domain and they handle it.\n switch ($objectType) {\n case 'lead':\n case 'account':\n case 'contact':\n case 'opportunity':\n case 'task':\n case 'event':\n case 'activity':\n\n $url = $this->config->crm_base_url . '/' . $providerId;\n\n break;\n }\n\n return $url;\n }\n\n public function buildTaskSearchFields(): array\n {\n return ['Id', 'WhoId', 'WhatId', 'AccountId'];\n }\n\n public function getTaskByFilterConditions(\n array $fields,\n array $filters,\n bool $bulkSearch = false,\n bool $strictFilters = true\n ): ?array {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $query = $queryBuilder->buildSearchTaskQuery($fields, $filters, $bulkSearch, $strictFilters);\n\n try {\n if (! $bulkSearch) {\n $objects = $this->queryHandler->query($query, $filters);\n if ($objects->count() === 1) {\n return $objects->current();\n }\n }\n\n if ($bulkSearch) {\n $objects = $this->queryHandler->query($query);\n $records = [];\n foreach ($objects as $record) {\n $key = $record[end($fields)];\n $records[$key] = $record;\n }\n\n return $records;\n }\n } catch (\\Exception $e) {\n $this->logger->info('[Salesforce] Failed to execute query', [\n 'query' => $query,\n 'error' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function mapCrmObjects(array $task): array\n {\n $activityData = [];\n\n if (! empty($task['WhoId'])) {\n $type = $this->parseObjectType($task['WhoId']);\n $activityData[$type] = $task['WhoId'];\n }\n if (! empty($task['AccountId'])) {\n $activityData['account'] = $task['AccountId'];\n }\n if (! empty($task['WhatId'])) {\n $activityData['opportunity'] = $task['WhatId'];\n }\n\n return $activityData;\n }\n\n /**\n * Get SF task by Outreach call id.\n */\n public function getTaskByFilter(\n string $activityFieldType,\n array $filters,\n string $operator = '=',\n array $additionalFields = []\n ): ?array {\n $data = [];\n\n try {\n // Default (base) fields.\n $fields = ['Id', 'Subject', 'Description', 'ActivityDate', 'WhoId', 'WhatId', $activityFieldType];\n\n foreach ($additionalFields as $additionalField) {\n $fields[] = $additionalField->crm_provider_id;\n }\n\n $fields = array_unique($fields);\n\n // Find task with the same Outreach id as the call id.\n $query = 'SELECT ' . implode(',', $fields) . '\n FROM Task\n WHERE IsArchived = false AND IsDeleted = false';\n\n foreach ($filters as $key => $value) {\n $key = preg_quote($key, '/');\n $key = str_replace(['\\'', '\"'], '', $key);\n // Prepare the substitution.\n $strKey = \":$key\";\n\n $query .= \" AND $key $operator $strKey\";\n }\n\n $query .= ' ORDER BY LastModifiedDate DESC LIMIT 1';\n\n $objects = $this->queryHandler->query($query, $filters);\n\n // There should be only one task related to this call if any.\n if ($objects->count() === 1) {\n $object = $objects->current();\n\n $dueDate = $object['ActivityDate'] ? Carbon::parse($object['ActivityDate'])->toIso8601String() : null;\n\n $data = array_merge($object, [\n 'crmId' => $object['Id'],\n 'subject' => $object['Subject'],\n 'summary' => $object['Description'],\n 'due' => $dueDate,\n 'Type' => $object[$activityFieldType],\n ]);\n }\n } catch (NoResultsException $e) {\n // Filters don't match any records.\n } catch (ServiceUnavailableException $serviceUnavailableException) {\n // Service cannot be queried. We should probably log this.\n }\n\n return $data;\n }\n\n /**\n * Get Salesforce fields including datetime fields\n *\n * @param $objectType\n */\n private function getAllFieldsAsArray($objectType): array\n {\n $basicFields = [];\n // Not all users have access to all object fields.\n if ($this->profile->{$objectType . '_fields'}) {\n $basicFields = explode(',', $this->profile->{$objectType . '_fields'});\n }\n\n $extraFields = [\n 'CreatedDate',\n 'LastModifiedDate',\n 'IsDeleted',\n ];\n\n if ($objectType === self::OBJECT_OPPORTUNITY\n && $this->config->opportunity_value_field_id\n && ! in_array($this->config->opportunityValueField->crm_provider_id, $basicFields)\n ) {\n $extraFields[] = $this->config->opportunityValueField->crm_provider_id;\n }\n\n return array_unique(array_merge($basicFields, $extraFields));\n }\n\n /**\n * Generate transcription for activity description.\n */\n private function generateTranscription(Activity $activity): string\n {\n if (! ($this->config->store_transcript)) {\n // If sending transcription to activity toggle is disabled\n return '';\n }\n\n return $this->transcriptionService\n ->findTranscriptionByActivity($activity)\n ->map(static function (array $transcriptionSegment): string {\n return $transcriptionSegment['formattedStartsAt'] . ' | ' . $transcriptionSegment['transcript'];\n })\n ->implode(PHP_EOL);\n }\n\n /**\n * Find related Salesforce event based on activity data\n *\n * @return array<string>\n */\n public function fetchRelatedActivity(Activity $activity): array\n {\n $this->logger->info('[Salesforce] Searching for related activity', [\n 'activityId' => $activity->getUuid(),\n 'ownerId' => $this->profile?->crm_provider_id,\n ]);\n\n $sfEvent = $this->fetchRelatedEvent($activity);\n if (empty($sfEvent)) {\n $this->logger->info('[Salesforce] No related activity found', [\n 'activityId' => $activity->getUuid(),\n 'ownerId' => $this->profile?->crm_provider_id,\n 'account' => $activity->hasAccount()\n ? $activity->getAccount()->getCrmProviderId()\n : null,\n ]);\n\n return [];\n }\n\n return $sfEvent;\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n if ($activity->isTypeConference() === false) {\n return null;\n }\n\n if ($activity->hasActualStartTime() === false && $activity->hasScheduledStartTime() === false) {\n return null;\n }\n\n if (! $activity->hasProspect()) {\n $this->logger->info('[Salesforce] Skip look up, Activity not linked to Lead, Contact or Account', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n $playbook = $this->getPlaybook($activity->getUser());\n if ($playbook !== null && $playbook->getActivityType() === Playbook::ACTIVITY_TYPE_TASK) {\n $this->logger->info('[Salesforce] Skip auto-sync for task-based playbook', [\n 'activityUuid' => $activity->getUuid(),\n 'playbookId' => $playbook->getId(),\n 'playbookType' => $playbook->getActivityType(),\n ]);\n\n return null;\n }\n\n try {\n $sfEvent = $this->fetchRelatedActivity($activity);\n if (empty($sfEvent)) {\n return null;\n }\n\n [$activityField, $activityType] = $this->resolveActivityTypeFromEvent($activity, $sfEvent);\n\n $this->logger->info('[Salesforce] Found related activity', [\n 'activityId' => $activity->getUuid(),\n 'sfEvent' => $sfEvent['Id'],\n 'activityFieldName' => $activityField,\n 'crmActivityType' => ($activityField !== null && isset($sfEvent[$activityField]))\n ? $sfEvent[$activityField]\n : null,\n 'activityType' => $activityType,\n ]);\n\n $userId = $this->findRelatedActivityUserId($activity, $sfEvent);\n\n if ($activity->getUserId() !== $userId) {\n $this->logger->info('[Salesforce] Updating meeting owner', [\n 'activityId' => $activity->getUuid(),\n 'oldUserId' => $activity->getUserId(),\n 'newUserId' => $userId,\n ]);\n }\n\n $this->updateSfEventDescription($activity, $sfEvent);\n\n $activity->update([\n 'user_id' => $userId,\n 'crm_provider_id' => $sfEvent['Id'],\n 'playbook_category_id' => $activityType->id ?? $activity->getCategory()?->getId(),\n ]);\n\n $this->logger->info('[Salesforce] Activity updated', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return $activity;\n } catch (\\Exception $exception) {\n \\Sentry::captureException($exception);\n\n throw $exception;\n }\n }\n\n /**\n * @param array<string, mixed> $sfEvent\n *\n * @return array{0: string|null, 1: mixed}\n */\n private function resolveActivityTypeFromEvent(Activity $activity, array $sfEvent): array\n {\n $activityField = $this->getActivityFieldName($activity);\n $activityType = null;\n\n if ($activityField !== null && ! empty($sfEvent[$activityField])) {\n $playbook = $this->getPlaybook($activity->getUser());\n $activityType = $this->getPlaybookCategory($playbook, strval($sfEvent[$activityField]));\n }\n\n return [$activityField, $activityType];\n }\n\n /**\n * @param array<string> $sfEvent\n */\n private function findRelatedActivityUserId(Activity $activity, array $sfEvent): int\n {\n $userId = $activity->getUserId();\n\n if (empty($sfEvent['OwnerId']) === false) {\n $profile = $this\n ->config\n ->profiles()\n ->where('crm_provider_id', $sfEvent['OwnerId'])\n ->get()\n ->filter(static function (Profile $profile) use ($activity): bool {\n if (! $activity->isTypeConference()) {\n return ! empty($profile->user) ? $profile->user->isStatusActive() : false;\n }\n\n $participants = $activity->getParticipants();\n\n return ! empty($profile->user)\n ? $profile->user->isStatusActive()\n && $profile->user->hasPermission(PermissionEnum::RECORD_MEETING)\n && $participants->contains('user_id', $profile->user_id)\n : false;\n })\n ->first();\n\n if ($profile) {\n $userId = $profile->user_id;\n }\n }\n\n return $userId;\n }\n\n /**\n * @param array<string, mixed> $sfEvent\n */\n private function updateSfEventDescription(Activity $activity, array $sfEvent): void\n {\n try {\n if (str_contains($sfEvent['Description'], $activity->id_string)) {\n return;\n }\n\n $payload = [\n 'Description' => $sfEvent['Description']\n . PHP_EOL\n . PHP_EOL\n . (new DecorateActivity())->generateDescription($activity),\n ];\n\n $this->logger->info('[Salesforce] Update record', [\n 'activityId' => $activity->getUuid(),\n 'sfEvent' => $sfEvent['Id'],\n 'payload' => $payload,\n ]);\n\n $payload = array_merge(\n $payload,\n $this->payloadBuilder->fetchCustomFieldData($activity, Field::OBJECT_EVENT)\n );\n\n $this->updateRecord('Event', $sfEvent['Id'], $payload);\n } catch (\\Exception) {\n $this->logger->error('[Salesforce] Failed to update record', [\n 'activityUuid' => $activity->getUuid(),\n 'sfEvent' => $sfEvent['Id'],\n ]);\n }\n }\n\n /**\n * Returns the most recently modified Event within time range (if any).\n *\n * @return array|null An Event record from Salesforce.\n */\n private function fetchRelatedEvent(Activity $activity): ?array\n {\n $ownerId = $this->profile?->crm_provider_id;\n if ($ownerId === null) {\n return [];\n }\n\n /** @var ?Carbon $from */\n /** @var ?Carbon $to */\n [$from, $to] = $this->getFromToDates($activity);\n\n try {\n $whoId = null;\n $hasWho = $activity->lead_id || $activity->contact_id;\n if ($hasWho) {\n $whoId = $activity->hasLead()\n ? $activity->getLead()->crm_provider_id\n : $activity->getContact()->crm_provider_id;\n }\n\n if ($hasWho === false && $activity->account_id === null) {\n return null;\n }\n\n $query = $this->buildFetchRelatedEventQuery($activity);\n\n $objects = $this->queryHandler->query($query, [\n 'ownerId' => $ownerId,\n 'whoId' => $whoId,\n 'whatId' => $activity->hasOpportunity() ? $activity->getOpportunity()->crm_provider_id : null,\n 'accountId' => $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null,\n 'from' => $from?->format('Y-m-d\\TH:i:s\\Z'),\n 'to' => $to?->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($objects as $object) {\n return $object;\n }\n } catch (NoResultsException $e) {\n return [];\n }\n\n return [];\n }\n\n private function getFromToDates(Activity $activity): array\n {\n $from = null;\n $to = null;\n\n /** @var ?CalendarEvent $calendarEvent */\n $calendarEvent = $activity->calendarEvent()->first();\n if ($calendarEvent !== null) {\n $from = $calendarEvent->getStartTime();\n $to = $calendarEvent->getEndTime();\n }\n\n // For non-calendar imported activities\n // Also double check if calendar event dates could be null?\n // If null use what we've got so far\n if ($from === null || $to === null) {\n $from = $activity->hasScheduledStartTime()\n ? $activity->getScheduledStartTime()\n : $activity->getActualStartTime();\n $to = $activity->hasScheduledEndTime()\n ? $activity->getScheduledEndTime()->addMinutes(15)\n : $activity->getActualEndTime();\n }\n\n return [$from, $to];\n }\n\n /**\n * Determines the appropriate activity field name for querying Salesforce events.\n *\n * This method follows a hierarchy to determine the field name:\n * 1. Uses the playbook's activity field if it exists and is in the profile's accessible fields\n * 2. Falls back to the default activity field if the profile has no event fields configured\n * 3. Returns null if no suitable field is found\n *\n * @param Activity $activity The activity to determine the field for\n *\n * @return string|null The field name to use in queries, or null if none is available\n */\n private function getActivityFieldName(Activity $activity): ?string\n {\n if ($this->profile === null) {\n $this->logger->warning('[Salesforce] Cannot determine activity field - profile not found', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n $profileEventFields = $this->profile->getFieldsAsArray('event');\n\n if (empty($profileEventFields)) {\n $defaultActivityField = $this->getDefaultActivityField(Field::OBJECT_EVENT);\n $defaultFieldName = $defaultActivityField?->getAttribute('crm_provider_id');\n // Profile not yet synced — fall back to the default activity field.\n // There is a small chance that the profile won't have Default Activity Type field access\n // in which case the query will fail.\n // This is however an edge case and should be reviewed for profile sync issues.\n Sentry::withScope(function (\\Sentry\\State\\Scope $scope) use ($defaultFieldName): void {\n $scope->setContext('details', [\n 'profileId' => $this->profile->id,\n 'defaultField' => $defaultFieldName,\n ]);\n Sentry::captureMessage(\n '[Salesforce] Profile event fields empty, falling back to default activity field.',\n \\Sentry\\Severity::warning()\n );\n });\n\n return $defaultFieldName;\n }\n\n $playbook = $this->getPlaybook($activity->getUser());\n\n if (! is_null($playbook) && ! is_null($playbook->getActivityField())) {\n $playbookFieldName = $playbook->getActivityField()->getAttribute('crm_provider_id');\n\n if (in_array($playbookFieldName, $profileEventFields, true)) {\n return $playbookFieldName;\n }\n\n $this->logger->warning('[Salesforce] Playbook activity field not found in profile fields', [\n 'activityId' => $activity->getUuid(),\n 'playbookField' => $playbookFieldName,\n 'profileId' => $this->profile->id,\n ]);\n }\n\n return null;\n }\n\n private function buildFetchRelatedEventQuery(Activity $activity): string\n {\n $hasWho = $activity->lead_id || $activity->contact_id;\n\n $activityFieldName = $this->getActivityFieldName($activity);\n $fields = array_filter(['Id', 'Description', 'OwnerId', $activityFieldName]);\n\n $ownerCondition = '(OwnerId = :ownerId OR CreatedById = :ownerId)';\n\n $query = '\n SELECT ' . implode(',', $fields) . '\n FROM Event\n WHERE ' . $ownerCondition . '\n AND IsArchived = false\n AND IsAllDayEvent = false\n AND StartDateTime >= :from\n AND EndDateTime <= :to\n AND (';\n\n $operator = '';\n if ($activity->account_id) {\n // This covers events tied to a related contact or opportunity too.\n $query .= 'AccountId = :accountId';\n\n $operator = ' OR ';\n }\n\n if ($hasWho) {\n $query .= $operator . 'WhoId = :whoId';\n\n // If we are also going to check on a specific opportunity, set that up.\n if ($activity->opportunity_id) {\n $query .= ' OR WhatId = :whatId';\n }\n }\n\n $query .= ') ORDER BY LastModifiedDate DESC';\n\n return $query;\n }\n\n public function fetchProspect(array $task): array\n {\n $lead = $account = $opportunity = $contact = $stage = $countryCode = null;\n $externalId = $task['WhoId'] ?? null;\n\n // Lead or Contact\n if ($externalId) {\n try {\n [$lead, $account, $opportunity, $contact, $stage, $countryCode] = $this->parseRecords($externalId);\n } catch (\\InvalidArgumentException $exception) {\n // Invalid object type.\n }\n }\n\n // If we happen to know the opportunity or account from the Task, figure that out.\n if (empty($task['WhatId']) === false) {\n // WhatId could be either Account ID or Opportunity ID.\n // If WhatId is Opportunity ID, get the opportunity and stage from the CRM.\n try {\n [, $account, $opportunity, , $stage, ] = $this->parseRecords($task['WhatId']);\n } catch (\\InvalidArgumentException $exception) {\n // Invalid object type.\n }\n }\n\n return [$lead, $account, $opportunity, $contact, $stage, $countryCode];\n }\n\n /**\n * Save activity transcription summary as note\n */\n public function saveTranscriptionSummaryAsNote(\n ActivityContract $activity,\n string $title,\n string $body,\n ?string $objectId,\n ?NoteObject $noteObject = null,\n ): ?string {\n return $this->saveNote($title, $body, (string) $objectId);\n }\n\n public function getObjectByFilterConditions(string $objectType, array $fields, array $filters): ?array\n {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $query = $queryBuilder->buildObjectSearchQuery($objectType, $fields, $filters);\n\n try {\n $objects = $this->queryHandler->query($query, $filters);\n if ($objects->count() === 1) {\n return $objects->current();\n }\n } catch (\\Exception $e) {\n $this->logger->info('[Salesforce] Failed to execute query', [\n 'query' => $query,\n 'error' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n private function getCustomProfileRules(TeamRepository $teamRepository): array\n {\n $teamSettings = $teamRepository->getTeamSetting($this->team, 'custom_profile_validation');\n\n if ($teamSettings instanceof TeamSettings && $teamSettings->getValueType() === 'array') {\n $customRules = json_decode($teamSettings->getValue(), true);\n if (is_array($customRules)) {\n return $customRules;\n }\n }\n\n return [];\n }\n\n private function customProfileValidation(array $crmUser, array $customRules): bool\n {\n foreach ($customRules as $customRule) {\n if ($crmUser[$customRule['field']] !== $customRule['value']) {\n return false;\n }\n }\n\n return true;\n }\n\n /**\n * When syncing Contact / Lead / Account / Opportunity / Stage crm entities,\n * validate and restore locally trashed objects,\n * before updating them. Objects are identified by CrmProviderId\n */\n private function restoreAnyTrashedEntity(HasMany $targetEntity, string $crmProviderId): void\n {\n $recordExists = $targetEntity->withTrashed()->where(['crm_provider_id' => $crmProviderId])->first();\n if ($recordExists && $recordExists->trashed()) {\n $recordExists->restore();\n }\n }\n\n #[\\Override] public function supportsNotes(): bool\n {\n return true;\n }\n\n private function getOwnerProfile(?string $ownerId): ?Profile\n {\n if ($ownerId === null) {\n return null;\n }\n\n return $this->config->profiles()\n ->where('crm_provider_id', $ownerId)\n ->first();\n }\n}","depth":2,"bounds":{"left":0.44448137,"top":0.42697525,"width":0.28490692,"height":0.57302475},"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Salesforce;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse Illuminate\\Database\\Eloquent\\Relations\\HasMany;\nuse Illuminate\\Events\\Dispatcher;\nuse Illuminate\\Support\\Facades\\Cache;\nuse Illuminate\\Support\\Facades\\Log;\nuse Illuminate\\Support\\Str;\nuse Jiminny\\Component\\Country\\CountriesMap;\nuse Jiminny\\Contracts\\Acl\\PermissionEnum;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\FetchRelatedActivityInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\ImportsBusinessProcessesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\SalesforceBatchSyncInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\SalesforceInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteNoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SearchTaskInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SendSummaryToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SupportsObjectTypeParseInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmProfileRecordTypesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Enums\\CrmObject;\nuse Jiminny\\Events\\Activities\\Crm\\LeadConverted;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpBadRequestException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Exceptions\\NoResultsException;\nuse Jiminny\\Exceptions\\ServiceUnavailableException;\nuse Jiminny\\Jobs\\Crm\\NoteObject;\nuse Jiminny\\Jobs\\Crm\\MatchActivitiesToNewOpportunity;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Calendar\\CalendarEvent;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Contracts\\ActivityContract;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Configuration;\nuse Jiminny\\Models\\Crm\\ContactRole;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Crm\\RecordType;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\TeamSettings;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\ContactRoleRepository;\nuse Jiminny\\Repositories\\Crm\\FieldRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Repositories\\Crm\\RecordTypeFieldValuesRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\Helpers\\ArrayIterator;\nuse Jiminny\\Services\\Crm\\MatchDomainByEmailInterface;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Services\\Crm\\ResolveCompanyNameByEmailTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\Fields\\FieldHelper;\nuse Jiminny\\Services\\Crm\\Salesforce\\Fields\\FieldTypeConverter;\nuse Jiminny\\Services\\Crm\\Salesforce\\Fields\\ValueNormalizer;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\FollowupActivityTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\LogActivityTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\RecordManipulationsTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\SyncFieldsTrait;\nuse Jiminny\\Utils\\CurrencyFormatter;\nuse Jiminny\\Utils\\StringUtil;\nuse Ramsey\\Uuid\\Uuid;\nuse Sentry\\Laravel\\Facade as Sentry;\n\nclass Service extends BaseService implements\n SalesforceInterface,\n SalesforceBatchSyncInterface,\n SyncCrmEntitiesInterface,\n SyncCrmProfileRecordTypesInterface,\n ImportsBusinessProcessesInterface,\n RemoteEntityManipulationInterface,\n FetchRelatedActivityInterface,\n SendSummaryToCrmInterface,\n MatchDomainByEmailInterface,\n SearchTaskInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n SupportsObjectTypeParseInterface,\n RemoteNoteEntityManipulationInterface,\n VerifyTaskExistsInterface\n{\n use ResolveCompanyNameByEmailTrait;\n use SyncFieldsTrait;\n use DeleteObjectsTrait;\n use RecordManipulationsTrait;\n use ServiceTraits\\BatchSyncTrait;\n use FollowupActivityTrait;\n use LogActivityTrait;\n\n /**\n * Note Body Limit for the Old Note-Taking Tool\n *\n * @var int\n */\n private const int CLASSIC_NOTE_MAX_LENGTH = 32000;\n\n /**\n * Note Content Limit for the New Notes\n *\n * @var int\n */\n private const int ENHANCED_NOTE_MAX_LENGTH = 50000000;\n\n private const string INSTALLED_PACKAGE_ID = '033Tw0000007bKbIAI';\n\n private const int CACHE_TTL = 600;\n\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day - 86400\n\n /**\n * @var Client\n */\n protected $client;\n\n protected PayloadBuilder $payloadBuilder;\n protected QueryHandler $queryHandler;\n\n private OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n\n public function __construct(\n Client $client,\n PayloadBuilder $payloadBuilder,\n protected Dispatcher $eventDispatcher,\n private readonly CountriesMap $countriesMap,\n private readonly ProspectPhotoPathService $prospectPhotoPathService,\n ) {\n parent::__construct();\n\n $this->client = $client;\n $this->payloadBuilder = $payloadBuilder;\n $this->queryHandler = app(QueryHandler::class, [\n 'client' => $this->client,\n 'logger' => $this->logger,\n ]);\n $this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n }\n\n public function getDisplayName(): string\n {\n return 'Salesforce';\n }\n\n public function getJobDelay(): int\n {\n return 1;\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n return $user->getSocialAccount(SocialAccount::PROVIDER_SALESFORCE);\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($activity, $crmProviderId) {\n $playbook = $this->getPlaybookFromActivity($activity);\n\n if ($playbook === null) {\n $this->logger->warning('[Salesforce] Cannot verify task - no playbook found', [\n 'activity' => $activity->getId(),\n 'crm_provider_id' => $crmProviderId,\n ]);\n\n return false;\n }\n\n $objectType = $playbook->getActivityType() === Playbook::ACTIVITY_TYPE_EVENT ? 'Event' : 'Task';\n\n try {\n $record = $this->getRecord($objectType, $crmProviderId, ['Id', 'IsDeleted']);\n\n return ! empty($record) && ($record['IsDeleted'] ?? false) === false;\n } catch (HttpNotFoundException|HttpBadRequestException) {\n $this->logger->info('[Salesforce] Activity record not found during verification', [\n 'activity' => $activity->getId(),\n 'object_type' => $objectType,\n 'crm_provider_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n });\n }\n\n public function query(string $queryToRun, array $parameters = []): QueryIterator\n {\n // Due to poorly designed external calls, this method cannot be entirely removed\n return $this->queryHandler->query($queryToRun, $parameters);\n }\n\n /*=========== Organization Information ===============*/\n\n /**\n * Get a list of all the API Versions for the instance.\n *\n * @throws CrmException\n *\n * @return mixed\n *\n */\n public function getApiVersions()\n {\n $url = $this->config->crm_base_url . '/services/data';\n\n $response = $this->client->get($url);\n\n return json_decode($response->getBody(), true);\n }\n\n /**\n * Gets the valid recordTypes for a given Salesforce Object via the describe API.\n */\n private function getRecordTypes(string $crmObject): array\n {\n $url = $this->client->getObjectsUrl() . $crmObject . '/describe';\n\n $response = $this->client->get($url);\n $jsonResponse = json_decode($response->getBody(), true);\n\n $fields = [];\n foreach ($jsonResponse['recordTypeInfos'] as $row) {\n $fields[] = ['recordTypeId' => $row['recordTypeId'], 'default' => $row['defaultRecordTypeMapping']];\n }\n\n return $fields;\n }\n\n /**\n * Convert raw field data into a format compatible with CRM APIs.\n */\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return ValueNormalizer::normalize($fieldType, $fieldValue, $internal);\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n $defaultFields = ($activityType === Playbook::ACTIVITY_TYPE_TASK)\n ? FieldDefinitions::defaultTaskFields()\n : FieldDefinitions::defaultEventFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n // Setup the activity field as the default Type.\n /** @var Field $activityField */\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'Type',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK, Playbook::ACTIVITY_TYPE_EVENT];\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n\n $fieldFilter = ($activityType === Playbook::ACTIVITY_TYPE_TASK)\n ? FieldDefinitions::taskFollowupFieldsFilter()\n : FieldDefinitions::eventFollowupFieldsFilter();\n\n foreach ($fieldFilter as $eachFilter) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $eachFilter);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n /**\n * This one is now called only when ImportActivityTypes is triggered or SyncFieldMetadata executed manually\n * Regular sync now uses SharedSyncFieldsTrait -> syncSingleObjectType\n * Needs to be replaced later on\n */\n public function syncField(Field $field): void\n {\n try {\n if (FieldHelper::isCustomField($field)) {\n $query = '\n SELECT\n Id, Metadata, TableEnumOrId\n FROM\n CustomField\n WHERE\n DeveloperName = :fieldName\n AND\n TableEnumOrId = :fieldType\n AND\n NamespacePrefix = :namespacePrefix';\n\n // We need to constrain the field lookup to the object, in case it's used in multiple places.\n $objectType = \\in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true)\n ? 'activity'\n : $field->object_type;\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'fieldName' => substr($field->crm_provider_id, 0, -\\strlen('__c')),\n 'fieldType' => ucfirst($objectType),\n\n // This is used to ensure we only consider the field within the org, not installed packages.\n 'namespacePrefix' => 'null',\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n // Sync field metadata.\n $metadata = $sfField['Metadata'];\n\n $field->description = mb_strimwidth($metadata['description'] ?? '', 0, 191);\n $field->label = mb_strimwidth($metadata['label'] ?? '', 0, Field::LABEL_MAX_LENGTH);\n $field->type = $this->convertFieldType($metadata['type'], $field->getEntityName());\n $field->is_mandatory = ($metadata['required'] === true);\n $field->length = $metadata['length'];\n $field->default_value = mb_strimwidth(trim($metadata['defaultValue'] ?? '', '\"'), 0, 191);\n $field->save();\n } else {\n $query = '\n SELECT\n Id, DataType, DeveloperName, Label, Length, Description\n FROM\n FieldDefinition\n WHERE\n DurableId = :entityName';\n\n $entityName = $field->getEntityName();\n $sfFields = $this->queryHandler->metadata($query, [\n 'entityName' => $entityName,\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n $convertedType = $this->convertFieldType($sfField['DataType'], $entityName);\n $label = mb_strimwidth($sfField['Label'], 0, Field::LABEL_MAX_LENGTH);\n\n if ($field->isBusinessType()) {\n $label = 'Opportunity Type';\n }\n\n $field->description = mb_strimwidth($sfField['Description'], 0, Field::DESCRIPTION_MAX_LENGTH);\n $field->label = $label;\n $field->type = $convertedType;\n $field->length = $sfField['Length'];\n $field->save();\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n private function convertFieldType(string $from, ?string $entityName = null): string\n {\n $converter = new FieldTypeConverter();\n\n return $converter->convert($from, $entityName);\n }\n\n /**\n * @inheritdoc\n */\n public function importPicklistValues(Field $field): array\n {\n $values = [];\n $fieldValues = [];\n\n try {\n if (FieldHelper::isCustomField($field)) {\n $query = '\n SELECT\n Id, Metadata, TableEnumOrId\n FROM\n CustomField\n WHERE\n DeveloperName = :fieldName\n AND\n TableEnumOrId = :fieldType\n AND\n NamespacePrefix = :namespacePrefix';\n\n // We need to constrain the field lookup to the object, in case it's used in multiple places.\n $objectType = \\in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true) ?\n 'activity' : $field->object_type;\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'fieldName' => substr($field->crm_provider_id, 0, -\\strlen('__c')),\n 'fieldType' => ucfirst($objectType),\n // This is used to ensure we only consider the field within the org, not installed packages.\n 'namespacePrefix' => 'null',\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n $valueSet = $sfField['Metadata']['valueSet'];\n\n if ($valueSet['valueSetName'] === null) {\n // Local picklist values can be obtained easily.\n $picklistValues = $valueSet['valueSetDefinition']['value'];\n } else {\n // But for some fields, we just get the Global Value Picklist pointer so need to do more work.\n $picklistValues = $this->importGlobalValuePicklistValues($valueSet['valueSetName']);\n }\n\n // Import all active values.\n foreach ($picklistValues as $i => $sfFieldValue) {\n // Setup default value.\n if ($sfFieldValue['default']) {\n $field->update(['default_value' => $sfFieldValue['valueName']]);\n }\n\n // This comes through as null if active (lol).\n if ($sfFieldValue['isActive'] !== false) {\n $values[] = [\n 'value' => $sfFieldValue['valueName'],\n 'label' => $sfFieldValue['valueName'],\n 'sequence' => $i,\n 'is_default' => $sfFieldValue['default'],\n ];\n }\n }\n } else {\n $objectFields = $this->getObjectFields($field->object_type);\n $fieldId = $field->crm_provider_id;\n\n // Only work with our field of interest.\n $objectField = array_filter($objectFields, function ($item) use ($fieldId) {\n return $item['name'] === $fieldId;\n });\n\n $objectField = array_shift($objectField);\n if (empty($objectField['picklistValues']) === false) {\n foreach ($objectField['picklistValues'] as $i => $sfFieldValue) {\n // Skip inactive values.\n if ($sfFieldValue['active'] === false) {\n continue;\n }\n\n // Setup default value.\n if ($sfFieldValue['defaultValue']) {\n $field->update(['default_value' => $sfFieldValue['value']]);\n }\n\n $values[] = [\n 'value' => $sfFieldValue['value'],\n 'label' => $sfFieldValue['label'],\n 'sequence' => $i,\n 'is_default' => $sfFieldValue['defaultValue'],\n ];\n }\n }\n }\n\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n $value['value'] = substr($value['value'] ?? '', 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n if (($key = array_search($value['value'], $fieldsToPurge, true)) !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n // Get IDs of the values to be deleted\n $valuesToDelete = $field->values()->whereIn('value', $fieldsToPurge);\n $valuesToDeleteIds = $valuesToDelete->pluck('id');\n if (! $valuesToDeleteIds->isEmpty()) {\n $recordTypeFieldValuesRepository = app(RecordTypeFieldValuesRepository::class);\n $recordTypeFieldValuesRepository->deleteForCrmFieldValueIds($valuesToDeleteIds->toArray());\n\n // Now safely delete from crm_field_values\n $valuesToDelete->delete();\n }\n\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n return $fieldValues;\n }\n\n /**\n * Gets values from Global Value Picklists.\n */\n private function importGlobalValuePicklistValues(string $picklistName): array\n {\n $query = '\n SELECT\n Metadata\n FROM\n GlobalValueSet\n WHERE\n DeveloperName = :picklistName\n LIMIT 1';\n\n try {\n $sfValues = $this->queryHandler->metadata($query, [\n 'picklistName' => $picklistName,\n ]);\n\n // There is always 1 result at this point.\n $sfValue = $sfValues->current();\n\n return $sfValue['Metadata']['customValue'];\n } catch (NoResultsException $noResultsException) {\n // Nothing returned.\n\n return [];\n }\n }\n\n /**\n * @inheritdoc\n */\n public function syncProfileRecordTypes(): void\n {\n $objectTypes = [\n 'lead',\n 'account',\n 'contact',\n 'opportunity',\n 'task',\n 'event',\n ];\n\n foreach ($objectTypes as $objectType) {\n try {\n $crmRecordTypes = $this->getRecordTypes(ucfirst($objectType));\n\n foreach ($crmRecordTypes as $crmRecordType) {\n // If the record type is default and not the Master type, set this.\n if ($crmRecordType['default'] && $crmRecordType['recordTypeId'] !== '012000000000000AAA') {\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $crmRecordType['recordTypeId'])\n ->first();\n\n if ($recordType) {\n $this->profile->{$objectType . '_record_type_id'} = $recordType->id;\n }\n }\n }\n } catch (HttpNotFoundException $exception) {\n Log::error('No access to ' . $objectType . ' object, skipping...');\n\n // XXX: should we log this fact somewhere?\n continue;\n }\n }\n\n if ($this->profile->isDirty()) {\n $this->profile->save();\n }\n }\n\n /**\n * Gets business processes.\n */\n public function importBusinessProcesses(): void\n {\n $query = '\n SELECT\n Id, IsActive, Name, TableEnumOrId\n FROM\n BusinessProcess\n WHERE\n TableEnumOrId IN (\\'Lead\\',\\'Opportunity\\')';\n\n try {\n $sfProcesses = $this->queryHandler->query($query);\n\n // Upsert all processes for the team.\n foreach ($sfProcesses as $sfProcess) {\n /** @var BusinessProcess $businessProcess */\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $sfProcess['Id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => $sfProcess['Name'],\n 'type' => $sfProcess['TableEnumOrId'] === 'Lead' ? 'lead' : 'opportunity',\n 'is_selectable' => $sfProcess['IsActive'],\n ]);\n\n $this->importBusinessProcessStages($businessProcess);\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n /**\n * Gets business process stages.\n */\n private function importBusinessProcessStages(BusinessProcess $businessProcess): void\n {\n $query = '\n SELECT\n Metadata\n FROM\n BusinessProcess\n WHERE\n Id = :processId';\n\n try {\n $stages = [];\n $sfProcessStages = $this->queryHandler->metadata($query, [\n 'processId' => $businessProcess->crm_provider_id,\n ]);\n\n // There is always 1 result at this point.\n $sfProcessStage = $sfProcessStages->current();\n\n // Upsert all processes for the team.\n foreach ($sfProcessStage['Metadata']['values'] as $sfProcessStage) {\n $sanitizedName = urldecode($sfProcessStage['valueName']); // Must decode: \"%2C\" becomes \",\" etc.\n\n $stage = $businessProcess->crm->stages()\n // This MUST match on label because this API doesn't use API Name.\n ->where('label', $sanitizedName)\n ->where('type', $businessProcess->type)\n ->where('is_selectable', 1)\n ->first();\n\n if ($stage) {\n $stages[] = $stage->id;\n }\n }\n\n $businessProcess->stages()->sync($stages);\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n /**\n * Gets record types.\n */\n public function importRecordTypes(): void\n {\n $query = '\n SELECT\n Id, IsActive, Name, BusinessProcessId, SobjectType\n FROM\n RecordType';\n\n try {\n $sfRecordTypes = $this->queryHandler->query($query);\n\n // Upsert all record types for the process.\n foreach ($sfRecordTypes as $sfRecordType) {\n $businessProcess = null;\n if ($sfRecordType['BusinessProcessId']) {\n $businessProcess = $this->config->businessProcesses()\n ->where('crm_provider_id', $sfRecordType['BusinessProcessId'])\n ->first();\n }\n\n /** @var RecordType $recordType */\n $recordType = $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $sfRecordType['Id'],\n ], [\n 'team_id' => $this->team->id,\n 'type' => mb_strtolower($sfRecordType['SobjectType']),\n 'name' => $sfRecordType['Name'],\n 'is_selectable' => $sfRecordType['IsActive'],\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n $this->importRecordTypeFieldValues($recordType);\n }\n } catch (NoResultsException $noResultsException) {\n // Do nothing.\n }\n }\n\n /**\n * Import record type - field value mappings. This only works for standard fields.\n */\n private function importRecordTypeFieldValues(RecordType $recordType): void\n {\n try {\n $query = '\n SELECT\n Metadata\n FROM\n RecordType\n WHERE\n Id = :recordTypeId';\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'recordTypeId' => $recordType->crm_provider_id,\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n // Sync field metadata.\n $picklists = $sfField['Metadata']['picklistValues'];\n\n foreach ($picklists as $picklist) {\n $field = $this->config->fields()->where([\n 'type' => Field::TYPE_PICKLIST,\n 'object_type' => $recordType->type,\n 'crm_provider_id' => $picklist['picklist'],\n ])->first();\n\n if ($field) {\n $fieldValues = [];\n\n foreach ($picklist['values'] as $value) {\n // Must decode: \"%2C\" becomes \",\" etc.\n $fieldValue = $field->values()\n ->where('value', urldecode($value['valueName']))\n ->first();\n\n if ($fieldValue) {\n $fieldValues[] = $fieldValue->id;\n }\n }\n\n $recordType->fieldValues()->sync($fieldValues);\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n /**\n * @inheritdoc\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $params = [];\n $missingStage = null;\n if ($types === null) {\n $types = [Stage::TYPE_LEAD, Stage::TYPE_OPPORTUNITY];\n }\n\n foreach ($types as $type) {\n if ($type === Stage::TYPE_LEAD) {\n $query = '\n SELECT\n Id, ApiName, MasterLabel, SortOrder\n FROM\n LeadStatus';\n } else {\n $query = '\n SELECT\n Id, ApiName, MasterLabel, IsActive, SortOrder, DefaultProbability\n FROM\n OpportunityStage';\n }\n\n if ($missingStageName) {\n $escapedStageName = ValueNormalizer::replaceQueryWithStringLiterals($missingStageName);\n\n $query .= ' WHERE ApiName = :stageName';\n\n $params = [\n 'stageName' => $escapedStageName,\n ];\n }\n\n try {\n $sfStages = $this->queryHandler->query($query, $params);\n } catch (NoResultsException $exception) {\n $sfStages = [];\n }\n\n $missingStage = null;\n\n // Upsert all stages for the team.\n foreach ($sfStages as $sfStage) {\n $selectable = true;\n if (array_key_exists('IsActive', $sfStage)) {\n $selectable = $sfStage['IsActive'];\n }\n\n $this->restoreAnyTrashedEntity($this->config->stages(), $sfStage['Id']);\n\n $stage = $this->config->stages()->updateOrCreate([\n 'crm_provider_id' => $sfStage['Id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($sfStage['ApiName'], 0, 50),\n 'label' => mb_strimwidth($sfStage['MasterLabel'], 0, 191),\n 'type' => $type,\n 'sequence' => $sfStage['SortOrder'] ?? 0,\n 'is_selectable' => $selectable,\n 'probability' => $sfStage['DefaultProbability'] ?? null,\n ]);\n\n if ($missingStageName && $missingStageName === $sfStage['ApiName']) {\n $missingStage = $stage;\n }\n }\n\n if ($missingStageName && $missingStage === null) {\n // If they requested a stage that still doesn't exist, it must be inactive so lazy create it.\n $missingStage = $this->config->stages()->create([\n 'crm_provider_id' => Uuid::uuid4(),\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($missingStageName, 0, 50),\n 'label' => mb_strimwidth($missingStageName, 0, 191),\n 'type' => $type,\n 'sequence' => 0,\n 'is_selectable' => 0,\n ]);\n }\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int\n {\n $syncCount = 0;\n $fields = $this->getAllFieldsAsArray('lead');\n if (\\in_array('Id', $fields, true) === false) {\n return $syncCount;\n }\n\n $query = '\n SELECT ' . rtrim(implode(',', $fields), ',') . '\n FROM Lead\n WHERE LastModifiedDate > :since\n ORDER BY LastModifiedDate ASC';\n\n try {\n $sfLeads = $this->queryHandler->query($query, [\n 'since' => $since->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($sfLeads as $sfLead) {\n // Only sync if previously imported.\n if ($this->hasLead($sfLead['Id'])) {\n $this->importLead($sfLead);\n $syncCount++;\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::LEAD);\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLead(string $crmId): ?Lead\n {\n $fields = $this->getAllFieldsAsArray('lead');\n\n $sfLead = $this->getRecord('Lead', $crmId, $fields);\n\n return $this->importLead($sfLead);\n }\n\n private function importLead($crmData): ?Lead\n {\n /** @var ?Stage $stage */\n $stage = null;\n if (isset($crmData['Status'])) {\n // Get the current stage.\n $stage = $this->config\n ->stages()\n ->where('name', $crmData['Status'])\n ->where('type', Stage::TYPE_LEAD)\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_LEAD], $crmData['Status']);\n }\n }\n\n // If we have no way of importing this, just return null :(\n if ($stage === null) {\n return null;\n }\n\n $countryCode = $crmData['CountryCode'] ?? null;\n\n // Salesforce allows custom \"countries\" to be created. Disregard these.\n if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {\n $countryCode = null;\n }\n\n // If we have no country code, try to parse it from the country name.\n if ($countryCode === null && empty($crmData['Country']) !== false) {\n $countryCode = $this->convertCountryNameToCode($crmData['Country']);\n }\n\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($crmData['Phone'] ?? '', 0, 25);\n $parsedNumber = parsePhoneNumber($countryCode, $number);\n\n $mobilePhone = null;\n if (empty($crmData['MobilePhone']) === false) {\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($crmData['MobilePhone'], 0, 25);\n $mobilePhone = phone_e164($countryCode, $number);\n }\n\n $convertedDate = null;\n $convertedAccount = null;\n $convertedOpportunity = null;\n $convertedContact = null;\n\n if ($crmData['IsConverted'] == 'true') {\n $convertedDate = $crmData['ConvertedDate'];\n\n if (empty($crmData['ConvertedAccountId']) === false) {\n $convertedAccount = $this->config\n ->accounts()\n ->where('crm_provider_id', $crmData['ConvertedAccountId'])\n ->first();\n\n if ($convertedAccount === null) {\n try {\n $convertedAccount = $this->syncAccount($crmData['ConvertedAccountId']);\n } catch (HttpNotFoundException $exception) {\n // Probably the user has no permissions to access the converted data.\n }\n }\n }\n\n if (empty($crmData['ConvertedOpportunityId']) === false) {\n $convertedOpportunity = $this->config\n ->opportunities()\n ->where('crm_provider_id', $crmData['ConvertedOpportunityId'])\n ->first();\n\n if ($convertedOpportunity === null) {\n try {\n $convertedOpportunity = $this->syncOpportunity($crmData['ConvertedOpportunityId']);\n } catch (HttpNotFoundException $exception) {\n // Probably the user has no permissions to access the converted data.\n }\n }\n }\n\n if (empty($crmData['ConvertedContactId']) === false) {\n $convertedContact = $this->team\n ->crm\n ->contacts()\n ->where('crm_provider_id', $crmData['ConvertedContactId'])\n ->first();\n\n if ($convertedContact === null) {\n try {\n $convertedContact = $this->syncContact($crmData['ConvertedContactId']);\n } catch (HttpNotFoundException $exception) {\n // Probably the user has no permissions to access the converted data.\n }\n }\n }\n }\n\n if (empty($crmData['Company'])) {\n $company = 'Unknown';\n } else {\n $company = mb_strimwidth($crmData['Company'], 0, 191);\n }\n\n $domain = null;\n if (empty($crmData['Website']) === false) {\n $domain = mb_strimwidth($crmData['Website'], 0, 191);\n $domain = StringUtil::resolveDomain($domain);\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $data = [\n 'team_id' => $this->team->id,\n 'user_id' => $profile?->user_id,\n 'owner_id' => $crmData['OwnerId'] ?? '',\n 'company' => $company,\n 'domain' => $domain,\n 'name' => $crmData['Name'] ? mb_strimwidth($crmData['Name'], 0, 191) : '',\n 'title' => $crmData['Title'] ? mb_strimwidth($crmData['Title'], 0, 128) : null,\n 'email' => $crmData['Email'] ? mb_strimwidth($crmData['Email'], 0, 80) : null,\n 'phone' => $parsedNumber['phone'],\n 'ext' => $parsedNumber['ext'] ?? null,\n 'mobile_phone' => $mobilePhone,\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['Id'],\n modelType: Lead::class,\n fileName: $crmData['Id'],\n avatarText: $crmData['Name']\n ),\n 'stage_id' => $stage->id,\n 'record_type_id' => null,\n 'converted_at' => $convertedDate,\n 'converted_account_id' => $convertedAccount->id ?? null,\n 'converted_opportunity_id' => $convertedOpportunity->id ?? null,\n 'converted_contact_id' => $convertedContact->id ?? null,\n 'country_code' => $countryCode,\n 'remotely_created_at' => $createdDate,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->leads(), $crmData['Id']);\n\n /** @var Lead $lead */\n $lead = $this->config->leads()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n if ($lead->wasChanged('converted_at') && $lead->getConvertedAt() !== null) {\n $this->eventDispatcher->dispatch(new LeadConverted($lead));\n }\n\n $this->handleObjectDeletion($lead, $crmData);\n\n if ($lead->trashed()) {\n return null;\n }\n\n return $lead;\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccounts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n $fields = $this->getAllFieldsAsArray('account');\n\n if (\\in_array('Id', $fields, true) === false) {\n return $syncCount;\n }\n\n $query = '\n SELECT ' . rtrim(implode(',', $fields), ',') . '\n FROM Account\n WHERE LastModifiedDate > :since\n ORDER BY LastModifiedDate ASC';\n\n try {\n $sfAccounts = $this->queryHandler->query($query, [\n 'since' => $since->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($sfAccounts as $sfAccount) {\n // Only sync if previously imported.\n if ($this->hasAccount($sfAccount['Id'])) {\n $this->importAccount($sfAccount);\n $syncCount++;\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::ACCOUNT);\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccount(string $crmId): ?Account\n {\n $fields = $this->getAllFieldsAsArray('account');\n if (! in_array('Id', $fields, true)) {\n $this->logger->info('[Salesforce] Sync account cancelled. Fields are not available.', [\n 'crmId' => $crmId,\n 'userId' => $this->profile->getUserId(),\n ]);\n\n return null;\n }\n\n $sfAccount = $this->getRecord('Account', $crmId, $fields);\n\n return $this->importAccount($sfAccount);\n }\n\n private function importAccount($crmData): Account\n {\n $countryCode = $crmData['BillingCountryCode'] ?? $crmData['ShippingCountryCode'] ?? null;\n\n // Salesforce allows custom \"countries\" to be created. Disregard these.\n if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {\n $countryCode = null;\n }\n\n // If we have no country code, try to parse it from the country names.\n if ($countryCode === null && empty($crmData['BillingCountry']) === false) {\n $countryCode = $this->convertCountryNameToCode($crmData['BillingCountry']);\n }\n\n if ($countryCode === null && empty($crmData['ShippingCountry']) === false) {\n $countryCode = $this->convertCountryNameToCode($crmData['ShippingCountry']);\n }\n\n if (empty($crmData['Phone']) === false) {\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($crmData['Phone'], 0, 25);\n $parsedNumber = parsePhoneNumber($countryCode, $number);\n } else {\n $parsedNumber = [];\n }\n\n $industry = null;\n if (empty($crmData['Industry']) === false) {\n $industry = mb_strimwidth($crmData['Industry'], 0, 40);\n }\n\n $domain = null;\n if (empty($crmData['Website']) === false) {\n $domain = mb_strimwidth($crmData['Website'], 0, 191);\n $domain = StringUtil::resolveDomain($domain);\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $data = [\n 'team_id' => $this->team->id,\n 'user_id' => $profile?->user_id,\n 'owner_id' => $crmData['OwnerId'],\n 'name' => mb_strimwidth($crmData['Name'], 0, 191),\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['Id'],\n modelType: Account::class,\n fileName: $crmData['Id'],\n avatarText: $crmData['Name']\n ),\n 'industry' => $industry,\n 'domain' => $domain,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'ext' => $parsedNumber['ext'] ?? null,\n 'country_code' => $countryCode,\n 'remotely_created_at' => $createdDate,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->accounts(), $crmData['Id']);\n\n /** @var Account $account */\n $account = $this->config->accounts()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n $this->handleObjectDeletion($account, $crmData);\n\n return $account;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOpportunities(array $parameters, ?string $strategy = null): int\n {\n $strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);\n\n $syncCount = 0;\n $logParams = $parameters;\n $parameters['profile'] = $this->profile;\n $logParams['user'] = $this->profile->getUserId();\n\n if (count($strategies) > 1) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Multiple sync strategies used', [\n 'teamId' => $this->team->getUuid(),\n 'params' => $logParams,\n 'strategies_count' => count($strategies),\n ]);\n }\n\n foreach ($strategies as $syncStrategy) {\n $name = $syncStrategy->getStrategyName();\n\n try {\n $sfOpportunities = $syncStrategy->fetchOpportunities($parameters);\n $totalRecords = $sfOpportunities->count();\n\n foreach ($sfOpportunities as $sfOpportunity) {\n $this->importOpportunity($sfOpportunity);\n $syncCount++;\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n $this->logger->warning('[' . $this->getDisplayName() . '] No opportunities found', [\n 'teamId' => $this->team->getUuid(),\n 'name' => $name,\n 'params' => $logParams,\n 'reason' => $noResultsException->getMessage(),\n ]);\n } catch (CrmException $crmException) {\n // Nothing to sync.\n $this->logger->warning('[' . $this->getDisplayName() . '] Opportunity sync failed', [\n 'teamId' => $this->team->getUuid(),\n 'name' => $name,\n 'params' => $logParams,\n 'reason' => $crmException->getMessage(),\n ]);\n }\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::OPPORTUNITY, ['params' => $logParams]);\n\n // debug to see how if count of opportunities reaches 1000\n if ($syncCount >= 1000) {\n $this->logger->info(\n '[' . $this->getDisplayName() . '] Sync Opportunities - count warning',\n [\n 'team_id' => $this->team->getId(),\n 'params' => $logParams,\n 'count' => $syncCount,\n 'strategies_count' => count($strategies),\n 'total_records' => $totalRecords ?? null,\n ]\n );\n }\n\n return $syncCount;\n }\n\n\n /**\n * @inheritdoc\n */\n public function syncOpportunity(string $crmId): ?Opportunity\n {\n $strategy = $this->opportunitySyncStrategyResolver->resolve(\n $this->config,\n OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY\n );\n\n $parameters = [\n 'profile' => $this->profile,\n 'crm_id' => $crmId,\n ];\n\n try {\n $sfOpportunity = $strategy->fetchOpportunities($parameters);\n } catch (HttpNotFoundException $e) {\n $this->logger->info('[' . $this->getDisplayName() . '] Opportunity not found', [\n 'teamId' => $this->team->id_string,\n 'crmId' => $crmId,\n ]);\n\n return null;\n } catch (CrmException $crmException) {\n $this->logger->info('[' . $this->getDisplayName() . '] Opportunity sync failed', [\n 'teamId' => $this->team->id_string,\n 'crmId' => $crmId,\n 'exception' => $crmException->getMessage(),\n ]);\n\n return null;\n }\n\n if ($sfOpportunity instanceof ArrayIterator) {\n return $this->importOpportunity($sfOpportunity->getItems());\n }\n\n return $this->importOpportunity($sfOpportunity);\n }\n\n /**\n * @throws HttpNotFoundException\n */\n private function importOpportunity($crmData): ?Opportunity\n {\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $account = null;\n if (empty($crmData['AccountId']) === false) {\n /** @var ?Account $account */\n $account = $this->config->accounts()\n ->where('crm_provider_id', (string) $crmData['AccountId'])\n ->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmData['AccountId']);\n }\n }\n\n $userId = $profile?->getUserId() ?? $account?->getUserId();\n if ($userId === null) {\n $this->logger->error('[Salesforce] | Skip import, no user_id found', [\n 'id' => $crmData['Id'],\n ]);\n\n return null;\n }\n\n /** @var ?Stage $stage */\n $stage = null;\n if (isset($crmData['StageName'])) {\n $stage = $this->config\n ->stages()\n ->where('name', $crmData['StageName'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->orderBy('is_selectable', 'DESC')\n ->orderBy('id')\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $crmData['StageName']);\n }\n }\n\n $recordType = null;\n if (empty($crmData['RecordTypeId']) === false) {\n /** @var ?RecordType $recordType */\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $crmData['RecordTypeId'])\n ->first();\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $closeDate = null;\n if (empty($crmData['CloseDate']) === false) {\n $closeDate = Carbon::parse($crmData['CloseDate'])->format('Y-m-d');\n }\n\n $valueFieldName = 'Amount';\n if ($this->config->opportunity_value_field_id) {\n $valueFieldName = $this->config->opportunityValueField->crm_provider_id;\n }\n\n $data = [\n 'team_id' => $this->team->id,\n 'account_id' => $account->id ?? null,\n 'user_id' => $userId,\n 'owner_id' => $crmData['OwnerId'] ?? null,\n 'name' => mb_strimwidth($crmData['Name'] ?? '', 0, 128),\n 'value' => $crmData[$valueFieldName],\n 'currency_code' => CurrencyFormatter::formatCode($crmData['CurrencyIsoCode'] ?? null),\n 'close_date' => $closeDate,\n 'is_closed' => $crmData['IsClosed'],\n 'is_won' => $crmData['IsWon'],\n 'stage_id' => $stage?->id ?? null,\n 'record_type_id' => $recordType->id ?? null,\n 'remotely_created_at' => $createdDate,\n 'probability' => $crmData['Probability'] ?? null,\n 'forecast_category' => $crmData['ForecastCategoryName'] ?? null,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->opportunities(), $crmData['Id']);\n\n // Do not allow locked DB tables & other errors\n // to interrupt the process of reverting the trashed opportunities\n try {\n /** @var Opportunity $opportunity */\n $opportunity = $this->config->opportunities()\n ->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n // import external fields into crm_field_data if present\n $crmFields = $this->getOpportunitySyncableFields();\n\n $this->importOpportunityCrmFieldData($crmData, $crmFields, $opportunity->id);\n\n $this->handleObjectDeletion($opportunity, $crmData);\n\n if ($opportunity->trashed()) {\n return null;\n }\n\n if ($opportunity->wasRecentlyCreated) {\n MatchActivitiesToNewOpportunity::dispatch($opportunity->getId());\n }\n\n return $opportunity;\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n $this->logger->error('[Salesforce] importOpportunity failure.', [\n 'crm_provider_id' => $crmData['Id'],\n 'team_id' => $this->team->id,\n 'exception' => $exception->getMessage(),\n ]);\n\n $this->handleEntityDeletionByProviderId($this->config->opportunities(), $crmData);\n }\n\n return null;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContacts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n $fields = $this->getAllFieldsAsArray('contact');\n if (\\in_array('Id', $fields, true) === false) {\n return $syncCount;\n }\n\n $query = '\n SELECT ' . rtrim(implode(',', $fields), ',') . '\n FROM Contact\n WHERE LastModifiedDate > :since\n ORDER BY LastModifiedDate ASC';\n\n try {\n $sfContacts = $this->queryHandler->query($query, [\n 'since' => $since->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($sfContacts as $sfContact) {\n // Only sync if previously imported.\n if ($this->hasContact($sfContact['Id'])) {\n $this->importContact($sfContact);\n $syncCount++;\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::CONTACT);\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContact(string $crmId): ?Contact\n {\n $fields = $this->getAllFieldsAsArray('contact');\n if (! in_array('Id', $fields, true)) {\n $this->logger->info('[Salesforce] Sync contact cancelled. Fields are not available.', [\n 'crmId' => $crmId,\n 'userId' => $this->profile->getUserId(),\n ]);\n\n return null;\n }\n\n $sfContact = $this->getRecord('Contact', $crmId, $fields);\n\n return $this->importContact($sfContact);\n }\n\n private function importContact($crmData): ?Contact\n {\n $account = null;\n // Contacts may not have accounts...\n if (isset($crmData['AccountId'])) {\n $account = $this->config->accounts()\n ->where('crm_provider_id', (string) $crmData['AccountId'])\n ->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmData['AccountId']);\n }\n }\n\n $countryCode = $crmData['MailingCountryCode'] ?? null;\n\n // Salesforce allows custom \"countries\" to be created. Disregard these.\n if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {\n $countryCode = null;\n }\n\n // If we have no country code, try to parse it from the country name.\n if ($countryCode === null && empty($crmData['MailingCountry']) === false) {\n $countryCode = $this->convertCountryNameToCode($crmData['MailingCountry']);\n\n if ($countryCode === null && $account) {\n $countryCode = $account->country_code;\n }\n }\n\n $ext = null;\n $parsedNumber = [];\n if (empty($crmData['Phone']) === false) {\n $number = Str::limit($crmData['Phone'], 25, '');\n $parsedNumber = parsePhoneNumber($countryCode, $number);\n\n if (empty($parsedNumber['ext']) === false) {\n $ext = Str::limit($parsedNumber['ext'], 10, '');\n }\n }\n\n $mobileNumber = null;\n if (empty($crmData['MobilePhone']) === false) {\n $mobileNumber = Str::limit(phone_e164($countryCode, $crmData['MobilePhone']), 25, '');\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $data = [\n 'team_id' => $this->team->id,\n 'account_id' => $account->id ?? null,\n 'user_id' => $profile?->user_id,\n 'owner_id' => $crmData['OwnerId'] ?? null,\n 'name' => ($crmData['Name'] ?? null) !== null ? mb_strimwidth($crmData['Name'], 0, 100) : '',\n 'title' => ($crmData['Title'] ?? null) !== null ? mb_strimwidth($crmData['Title'], 0, 128) : null,\n 'email' => ($crmData['Email'] ?? null) !== null ? mb_strimwidth($crmData['Email'], 0, 191) : null,\n 'country_code' => $countryCode,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'ext' => $ext,\n 'mobile_phone' => $mobileNumber,\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['Id'],\n modelType: Contact::class,\n fileName: $crmData['Id'],\n avatarText: $crmData['Name']\n ),\n 'remotely_created_at' => $createdDate,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->contacts(), $crmData['Id']);\n\n /** @var Contact $contact */\n $contact = $this->config->contacts()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n $this->handleObjectDeletion($contact, $crmData);\n\n if ($contact->trashed()) {\n return null;\n }\n\n return $contact;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n $fields = [\n 'InstanceName',\n 'OrganizationType',\n 'IsSandbox',\n ];\n\n $orgValues = $this->getRecord('Organization', $this->config->crm_provider_id, $fields);\n\n $edition = null;\n switch ($orgValues['OrganizationType']) {\n case 'Developer Edition':\n $edition = Configuration::EDITION_DEVELOPER;\n\n break;\n\n case 'Professional Edition':\n $edition = Configuration::EDITION_PROFESSIONAL;\n\n break;\n\n case 'Enterprise Edition':\n $edition = Configuration::EDITION_ENTERPRISE;\n\n break;\n }\n\n $this->config->edition = $edition;\n $this->config->instance = $orgValues['InstanceName'];\n\n // XXX: How can this state be possible?\n if ($this->config->version === null) {\n $this->config->version = Client::MIN_API_VERSION;\n }\n\n $installedVersion = $this->getInstalledAppVersion();\n if ($installedVersion !== null) {\n $installedVersion = (string) $this->getInstalledAppVersion();\n }\n\n $this->config->installed_app_version = $installedVersion;\n\n $this->config->save();\n }\n\n public function getInstalledAppVersion(): ?string\n {\n try {\n $query = '\n SELECT\n SubscriberPackageVersion.MajorVersion,\n SubscriberPackageVersion.MinorVersion,\n SubscriberPackageVersion.PatchVersion,\n SubscriberPackageVersion.BuildNumber\n FROM\n InstalledSubscriberPackage\n WHERE\n SubscriberPackageId = :packageId\n ';\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'packageId' => self::INSTALLED_PACKAGE_ID,\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n // Grab version number.\n $version = $sfField['SubscriberPackageVersion']['MajorVersion'] .\n $sfField['SubscriberPackageVersion']['MinorVersion'] .\n $sfField['SubscriberPackageVersion']['PatchVersion'] .\n $sfField['SubscriberPackageVersion']['BuildNumber'];\n } catch (\\Exception) {\n $version = null;\n }\n\n return $version;\n }\n\n /**\n * Store transcripts as note.\n *\n * @throws \\Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // For SF we also check if Log Notes is enabled.\n if ($this->profile->log_notes === Profile::LOG_NOTE_NONE) {\n return;\n }\n\n if ($activity->opportunity_id && $activity->prospect === null) {\n return;\n }\n\n try {\n $transcriptionData = $this->generateTranscription($activity);\n\n $noteMaxLength = $this->profile->log_notes === Profile::LOG_NOTE_ENHANCED\n ? self::ENHANCED_NOTE_MAX_LENGTH\n : self::CLASSIC_NOTE_MAX_LENGTH;\n\n $title = 'Transcript for ';\n $title .= $activity->title ?? $activity->activity_title;\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $body = mb_strimwidth($transcriptionData, 0, $noteMaxLength);\n\n if ($activity->opportunity_id) {\n $objectId = $activity->opportunity->crm_provider_id;\n } else {\n $objectId = $activity->prospect->crm_provider_id;\n }\n\n $noteId = $this->saveNote($title, $body, $objectId);\n\n // Store crm logged id in transcription.\n $transcription = $activity->getTranscription();\n $transcription->crm_activity_id = $noteId;\n $transcription->save();\n } catch (\\Exception $e) {\n \\Sentry::captureException($e);\n }\n }\n\n public function saveNote(string $title, string $body, string $objectId, ?NoteObject $noteObject = null): ?string\n {\n $noteId = null;\n\n try {\n if ($this->profile->log_notes === Profile::LOG_NOTE_ENHANCED) {\n $noteId = $this->buildEnhancedNote($title, $body, $objectId);\n } else {\n $noteId = $this->buildClassicNote($title, $body, $objectId);\n }\n } catch (HttpNotFoundException $exception) {\n // The profile not having access to create Enhanced Notes. Set their preference to Classic.\n if ($this->profile->log_notes === Profile::LOG_NOTE_ENHANCED) {\n $this->profile->update([\n 'log_notes' => Profile::LOG_NOTE_CLASSIC,\n ]);\n }\n }\n\n return $noteId;\n }\n\n /**\n * This is using the \"Enhanced\" Notes feature, NOT the \"Notes & Attachments\" feature being deprecated.\n *\n * @url https://salesforce.stackexchange.com/questions/104408/how-can-i-create-an-account-note-or-contact-note-via-api-that-is-visible-in-sale\n */\n private function buildEnhancedNote(string $title, string $body, string $objectId): string\n {\n // Decode stored entities, escape HTML (without quoting), then convert line breaks for Salesforce formatting\n $decodedBody = html_entity_decode($body, ENT_QUOTES | ENT_HTML5);\n $sanitizedBody = htmlspecialchars($decodedBody, ENT_NOQUOTES, 'UTF-8', false);\n $content = nl2br($sanitizedBody, false);\n $note = [\n 'OwnerId' => $this->profile->crm_provider_id,\n 'Title' => $title,\n 'Content' => base64_encode($content),\n ];\n\n $noteId = $this->createRecord('ContentNote', $note);\n\n $link = [\n 'ContentDocumentId' => $noteId,\n 'LinkedEntityId' => $objectId,\n 'ShareType' => 'I',\n ];\n\n $this->createRecord('ContentDocumentLink', $link);\n\n return $noteId;\n }\n\n private function buildClassicNote(string $title, string $body, string $objectId): string\n {\n if (in_array($this->parseObjectType($objectId), [Field::OBJECT_TASK, Field::OBJECT_EVENT])) {\n $this->logger->info('[Salesforce] Summary not sent', [\n 'profile_id' => $this->profile->id,\n 'objectId' => $objectId,\n 'reason' => 'Classical Note does not support Task/Event relation',\n ]);\n\n return '';\n }\n\n $titleTrimmed = null;\n\n if (mb_strlen($title) > 80) {\n $titleTrimmed = substr($title, 0, 77) . '...';\n }\n $payload = [\n 'OwnerId' => $this->profile->crm_provider_id,\n 'IsPrivate' => false,\n 'Title' => $titleTrimmed ?? $title,\n 'Body' => $titleTrimmed ? $title . PHP_EOL . $body : $body,\n 'ParentId' => $objectId,\n ];\n\n return $this->createRecord('Note', $payload);\n }\n\n /**\n * @inheritdoc\n */\n public function find(string $name, array $scopes): array\n {\n if ($this->profile === null) {\n return [];\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $limitValues = ['limit' => $this->limit, 'offset' => $this->offset];\n $sosl = $queryBuilder->buildFindQuery($name, $scopes, $limitValues);\n\n $this->logger->info('[Salesforce] Find prospects', [\n 'profile_id' => $this->profile->id,\n 'sosl_query' => $sosl,\n 'search_string' => $name,\n 'scopes' => $scopes,\n ]);\n\n $data = Cache::remember($this->profile->id . $sosl, self::CACHE_TTL, function () use ($sosl) {\n $data = [];\n\n try {\n // Hit remote API.\n $objects = $this->queryHandler->search($sosl);\n\n // Build mapped list.\n foreach ($objects as $object) {\n $type = strtolower($object['attributes']['type']);\n\n $record = [\n 'crmId' => $object['Id'],\n 'name' => $object['Name'],\n 'prospectType' => $type,\n 'phoneNumbers' => [],\n 'crmUrl' => $this->generateProviderUrl($object['Id'], $type),\n ];\n\n switch ($type) {\n case 'lead':\n if (empty($object['Company']) === false) {\n $record['organization'] = $object['Company'];\n }\n\n if (empty($object['Title']) === false) {\n $record['title'] = $object['Title'];\n }\n\n $stage = $this->config->stages()\n ->where('type', Stage::TYPE_LEAD)\n ->where('name', $object['Status'])\n ->first();\n\n // Lazy create the stage.\n if ($stage === null) {\n $stage = $this->importStages([Stage::TYPE_LEAD], $object['Status']);\n }\n\n if ($stage) {\n $record += [\n 'stage' => [\n 'id' => $stage->id_string,\n 'name' => $stage->name,\n ],\n ];\n }\n\n if (empty($object['RecordTypeId']) === false) {\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $object['RecordTypeId'])\n ->first();\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n }\n\n break;\n\n case 'account':\n if (empty($object['Industry']) === false) {\n $record['industry'] = $object['Industry'];\n $record['detailsLine'] = $object['Industry'];\n }\n if (! empty($object['PersonEmail'])) {\n $record['detailsLine'] = $object['PersonEmail'];\n }\n\n break;\n\n case 'contact':\n // For contacts, we should try and fetch their account name too.\n if ($object['AccountId']) {\n // Cheaper to get this locally.\n $account = $this->config->accounts()\n ->where('crm_provider_id', $object['AccountId'])\n ->first(['name']);\n\n if ($account) {\n $record['organization'] = $account->name;\n }\n }\n\n if (! empty($object['IsPersonAccount']) && $object['Email']) {\n $record['detailsLine'] = $object['Email'];\n } else {\n if (empty($object['Title']) === false) {\n $record['title'] = $object['Title'];\n }\n }\n\n break;\n }\n\n // Add phone numbers to record.\n if (empty($object['Phone']) === false && $object['Phone']) {\n $record['phoneNumbers'][] = [\n 'number' => $object['Phone'],\n 'nationalFormat' => phone_national($this->profile->user->country_code, $object['Phone']),\n 'type' => 'phone',\n ];\n }\n\n if (empty($object['MobilePhone']) === false && $object['MobilePhone']) {\n $record['phoneNumbers'][] = [\n 'number' => $object['MobilePhone'],\n 'nationalFormat' => phone_national(\n $this->profile->user->country_code,\n $object['MobilePhone']\n ),\n 'type' => 'mobile',\n ];\n }\n\n $data[] = $record;\n }\n } catch (NoResultsException $e) {\n $data = [];\n }\n\n return $data;\n });\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n\n if ($crmAccountId === null) {\n return $data;\n }\n\n if ($userId) {\n $profileRepository = app(ProfileRepository::class);\n $profile = $profileRepository->findProfileByUserId($this->config, $userId);\n\n $ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;\n }\n\n try {\n // Perhaps their profile has no opportunity permissions.\n if ($this->profile === null || $this->profile->opportunity_fields === null) {\n return $data;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $query = $queryBuilder->buildFindOpportunitiesQuery();\n\n $objects = $this->queryHandler->query($query, ['accountId' => $crmAccountId]);\n\n foreach ($objects as $object) {\n $record = [\n 'crmId' => $object['Id'],\n 'name' => $object['Name'],\n 'won' => $object['IsWon'],\n 'closed' => $object['IsClosed'],\n ];\n\n $valueFieldName = 'Amount';\n if ($this->config->opportunity_value_field_id) {\n $valueFieldName = $this->config->opportunityValueField->crm_provider_id;\n }\n\n if (empty($object[$valueFieldName]) === false) {\n $currency = $object['CurrencyIsoCode'] ?? $this->config->default_currency;\n $value = formatCurrency($object[$valueFieldName], $currency);\n\n $record += [\n 'value' => $value,\n ];\n }\n\n $stage = $this->config->stages()\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->where('name', $object['StageName'])\n ->first();\n\n // Lazy create the stage.\n if ($stage === null) {\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $object['StageName']);\n }\n\n $record += [\n 'stage' => [\n 'id' => $stage->id_string,\n 'name' => $stage->name,\n ],\n ];\n\n if (empty($object['RecordTypeId']) === false) {\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $object['RecordTypeId'])\n ->first();\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n }\n\n if ($ownerId && isset($object['OwnerId']) && $object['OwnerId'] === $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n } catch (NoResultsException $e) {\n return $data;\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n public function getContactRolesFromCrm(?Carbon $since = null): array\n {\n $roles = [];\n\n if ($this->profile === null) {\n return $roles;\n }\n\n $queryBuilder = app(QueryBuilder::class, ['profile' => $this->profile]);\n\n $query = $queryBuilder->buildGetContactRolesQuery($since);\n\n try {\n $objects = $this->queryHandler->query($query);\n\n foreach ($objects as $object) {\n $roles[] = [\n 'id' => $object['Id'],\n 'contactId' => $object['ContactId'],\n 'opportunityId' => $object['OpportunityId'],\n 'ownerId' => $object['Opportunity']['OwnerId'] ?? null,\n 'isPrimary' => $object['IsPrimary'],\n 'role' => $object['Role'],\n ];\n }\n } catch (NoResultsException) {\n // Just return an empty array.\n $this->logger->info('[Salesforce] No contact roles found', [\n 'since' => $since?->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n }\n\n return $roles;\n }\n\n public function syncContactRoles(Carbon $since): int\n {\n $contactRoleRepository = app(ContactRoleRepository::class);\n\n $crmContactRoles = $this->getContactRolesFromCrm(since: $since);\n $syncCount = 0;\n $contactRoles = [];\n\n foreach ($crmContactRoles as $crmContactRole) {\n $contactRoles[] = $this->importContactRole($crmContactRole);\n $syncCount++;\n }\n\n $contactRoleRepository->saveContactRoles($contactRoles);\n\n $this->syncRemotelyDeletedContactRoles();\n\n return $syncCount;\n }\n\n private function importContactRole(array $contactRole): array\n {\n $contact = $this->config->contacts()\n ->where('crm_provider_id', $contactRole['contactId'])\n ->first();\n\n if ($contact === null) {\n $contact = $this->syncContact($contactRole['contactId']);\n }\n\n $opportunity = $this->config->opportunities()\n ->where('crm_provider_id', $contactRole['opportunityId'])\n ->first();\n\n if ($opportunity === null) {\n $opportunity = $this->syncOpportunity($contactRole['opportunityId']);\n }\n\n $role = null;\n if (! empty($contactRole['role'])) {\n $role = mb_strimwidth($contactRole['role'], 0, 191);\n }\n\n return [\n 'crm_configuration_id' => $this->config->getId(),\n 'contact_id' => $contact->getId(),\n 'crm_provider_id' => $contactRole['id'],\n 'subject_type' => ContactRole::SUBJECT_TYPE_OPPORTUNITY,\n 'subject_id' => $opportunity->getId(),\n 'is_primary' => $contactRole['isPrimary'],\n 'role' => $role,\n ];\n }\n\n protected function syncRemotelyDeletedContactRoles(): bool\n {\n try {\n $deletedRemotely = $this->queryHandler->queryDeleted('OpportunityContactRole');\n } catch (NoResultsException $e) {\n return false;\n }\n\n $deletedOpportunities = $deletedRemotely->getResults();\n $deletedIds = array_column($deletedOpportunities, 'id');\n\n $contactRoleRepository = app(ContactRoleRepository::class);\n\n foreach (array_chunk($deletedIds, self::HARD_DELETE_CHUNK) as $chunk) {\n $contactRoleRepository->deleteContactRoles($chunk);\n\n $this->logger->info('[' . $this->getDisplayName() . '] Remotely deleted opportunities synced', [\n 'teamId' => $this->team->id_string,\n 'remotelyDeletedOpportunities' => $chunk,\n 'count' => count($chunk),\n ]);\n }\n\n return true;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = $objects = [];\n\n $hasWho = \\in_array($objectType, ['lead', 'contact']);\n $playbook = $this->getPlaybook($this->profile->user);\n $fields = array_merge(\n $this->profile->getFieldsAsArray(parent::OBJECT_TASK),\n $playbook && $playbook->activityField ? [$playbook->activityField->crm_provider_id] : []\n );\n\n // Query should default to any open call for that user.\n $query = '\n SELECT ' . implode(',', array_unique($fields)) . '\n FROM Task\n WHERE OwnerId = :ownerId\n AND IsArchived = false\n AND IsDeleted = false\n AND IsClosed = false\n AND (';\n\n if ($objectType === 'account') {\n // This covers tasks tied to a related contact or opportunity too.\n $query .= '\n AccountId = :accountId';\n }\n\n if ($hasWho) {\n $query .= '\n WhoId = :whoId';\n\n // If we are also going to check on a specific opportunity, set that up.\n if ($opportunityId) {\n $query .= ' OR WhatId = :whatId';\n }\n }\n\n $query .= ' ) ORDER BY LastModifiedDate DESC';\n\n try {\n $objects = $this->queryHandler->query($query, [\n 'ownerId' => $this->profile->crm_provider_id,\n 'whoId' => $objectId,\n 'whatId' => $opportunityId,\n 'accountId' => $objectId,\n ]);\n } catch (NoResultsException $e) {\n return $data;\n } finally {\n $this->logger->debug(sprintf('[Salesforce] Found %s tasks for query \"%s\"', count($objects), $query));\n }\n\n foreach ($objects as $object) {\n $dueDate = $object['ActivityDate'] ? Carbon::parse($object['ActivityDate'])->toIso8601String() : null;\n $data[] = [\n 'crmId' => $object['Id'],\n 'subject' => $object['Subject'],\n 'due' => $dueDate,\n 'type' => $object[$playbook->activityField->crm_provider_id],\n ];\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getEvents(string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = $objects = [];\n $user = $this->profile?->user;\n if ($this->profile === null || $user === null) {\n return $data;\n }\n\n $hasWho = \\in_array($objectType, ['lead', 'contact']);\n $playbook = $this->getPlaybook($user);\n $fields = array_merge(\n $this->profile->getFieldsAsArray(parent::OBJECT_EVENT),\n $playbook && $playbook->activityField ? [$playbook->activityField->crm_provider_id] : []\n );\n\n // Query should default to any event starting in the last week and ending up until today owned by the user.\n $query = '\n SELECT ' . implode(',', array_unique($fields)) . '\n FROM Event\n WHERE OwnerId = :ownerId\n AND IsArchived = false\n AND IsAllDayEvent = false\n AND StartDateTime >= LAST_N_DAYS:7\n AND EndDateTime <= TODAY\n AND (';\n\n if ($objectType === 'account') {\n // This covers events tied to a related contact or opportunity too.\n $query .= '\n AccountId = :accountId';\n }\n\n if ($hasWho) {\n $query .= '\n WhoId = :whoId';\n\n // If we are also going to check on a specific opportunity, set that up.\n if ($opportunityId) {\n $query .= ' OR WhatId = :whatId';\n }\n }\n\n $query .= ' ) ORDER BY LastModifiedDate DESC';\n\n try {\n $objects = $this->queryHandler->query($query, [\n 'ownerId' => $this->profile->crm_provider_id,\n 'whoId' => $objectId,\n 'whatId' => $opportunityId,\n 'accountId' => $objectId,\n ]);\n } catch (NoResultsException $e) {\n return $data;\n } finally {\n $this->logger->debug(sprintf('[Salesforce] Found %s tasks for query \"%s\"', count($objects), $query));\n }\n\n foreach ($objects as $object) {\n $dueDate = $object['StartDateTime'] ? Carbon::parse($object['StartDateTime'])->toIso8601String() : null;\n\n $data[] = [\n 'crmId' => $object['Id'],\n 'subject' => $object['Subject'],\n 'due' => $dueDate,\n 'type' => $object[$playbook->activityField->crm_provider_id],\n ];\n }\n\n return $data;\n }\n\n /**\n * Try to find CRM Objects using email address\n *\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchExactlyByEmail(string $email, ?int $userId = null): ?array\n {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $sosl = $queryBuilder->buildMatchByQuery($email, Field::TYPE_EMAIL);\n if ($sosl === null) {\n return null;\n }\n\n try {\n $objects = $this->queryHandler->search($sosl);\n $objects = $this->queryHandler->prioritiseResults(\n $objects,\n $email,\n QueryHandler::PRIORITISE_EMAIL\n );\n\n $data = $this->convertCrmData($objects, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (NoResultsException $e) {\n // Try the account next.\n if ($this->profile->account_fields === null) {\n return null;\n }\n }\n\n return null;\n }\n\n public function getDomain(string $email): ?string\n {\n // SF improved search - strip the domain extension, min domain name length 4\n return $this->getCompanyNameFromEmail(email: $email, minNameLength: 4);\n }\n\n /**\n * Try to find CRM objects using domain name of the email address\n *\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByDomain(string $domain, ?int $userId = null): ?array\n {\n $companyName = $domain;\n\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $sosl = $queryBuilder->buildMatchByDomainQuery($companyName);\n\n try {\n $objects = $this->queryHandler->search($sosl);\n\n $data = $this->convertCrmData($objects, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (NoResultsException) {\n return null;\n }\n }\n\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n // Don't bother looking up numbers that are masked.\n if (str_contains($phone, '**')) {\n return null;\n }\n\n if ($this->isPhoneNumberOfTeamMember($phone)) {\n return null;\n }\n\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $phoneNational = phone_national(null, $phone) ?? '';\n $possiblePhoneFormats = collect([\n preg_replace('/\\D/', '', ltrim($phone, '0+')),\n preg_replace('/\\D/', '', $phoneNational),\n formatDashPhoneNumber($phone),\n $phoneNational,\n ])\n ->filter() // Removes null and empty strings\n ->unique()\n ->values();\n\n foreach ($possiblePhoneFormats as $phone) {\n $sosl = $queryBuilder->buildMatchByQuery($phone, Field::TYPE_PHONE);\n if ($sosl === null) {\n continue;\n }\n\n try {\n $objects = $this->queryHandler->search($sosl);\n $objects = $this->queryHandler->prioritiseResults(\n $objects,\n $phone,\n QueryHandler::PRIORITISE_PHONE\n );\n\n return $this->convertCrmData($objects, $userId);\n } catch (NoResultsException) {\n continue;\n }\n }\n\n return null;\n }\n\n private function isPhoneNumberOfTeamMember(string $phone): bool\n {\n $teamRepository = app(TeamRepository::class);\n $user = $teamRepository->findTeamMemberByPhone($this->team, $phone);\n\n if ($user instanceof User) {\n return true;\n }\n\n return false;\n }\n\n protected function getCacheKey(string $object, ?int $userId = null): ?string\n {\n $key = $this->profile->id . $object;\n $keySuffix = $this->getOwnerKeySuffix($userId);\n\n return $key . $keySuffix;\n }\n\n private function getOwnerKeySuffix(?int $userId = null): string\n {\n return $userId === null ? '' : (string) $userId;\n }\n\n /** Determine the CRM Objects which represent the call activity. */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n if ($this->profile === null) {\n return null;\n }\n\n $cacheKey = $this->getCacheKey($name, $userId);\n\n $result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $sosl = $queryBuilder->buildMatchByQuery($name, 'name');\n if ($sosl === null) {\n return false;\n }\n\n try {\n $objects = $this->queryHandler->search($sosl);\n } catch (NoResultsException $e) {\n return false;\n }\n\n $objects = $this->queryHandler->prioritiseResults(\n $objects,\n $name,\n QueryHandler::PRIORITISE_NAME\n );\n\n $data = $this->convertCrmData($objects, $userId);\n\n return (! empty(array_filter($data))) ? $data : false;\n });\n\n return is_array($result) ? $result : null;\n }\n\n /**\n * @return array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n protected function convertCrmData(QueryIterator $objects, ?int $userId = null): array\n {\n $lead = null;\n $contact = null;\n $opportunity = null;\n $account = null;\n $stage = null;\n $countryCode = null;\n\n if ($objects->count() > 0) {\n $object = $objects->current();\n\n if ($object['attributes']['type'] === 'Lead') {\n $lead = $this->importLead($object);\n\n // Lead might not be imported if the Stage is null for example.\n if ($lead) {\n $countryCode = $lead->country_code;\n $stage = $lead->stage;\n }\n } else {\n if ($object['attributes']['type'] === 'Contact') {\n $contact = $this->importContact($object);\n $account = $contact->account;\n } else {\n $account = $this->importAccount($object);\n }\n\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account) {\n $countryCode = $account->country_code;\n }\n\n try {\n $sfOpportunities = $this->findOpportunities(\n $account?->getCrmProviderId(),\n $contact?->getCrmProviderId(),\n $userId\n );\n\n // Take the first opportunity, which will be ordered as priority based on their settings.\n if (! empty($sfOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($sfOpportunities[0]['crmId']);\n $stage = $opportunity?->stage;\n }\n } catch (Exception) {\n // Nothing to see here.\n }\n }\n }\n\n return [\n $lead,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n /**\n * @inheritdoc\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n if ($stage->type === Stage::TYPE_LEAD) {\n $objectType = 'Lead';\n $objectId = $crmObject->crm_provider_id;\n $objectStageType = 'Status';\n } else {\n $objectType = 'Opportunity';\n $objectId = $crmObject->crm_provider_id;\n $objectStageType = 'StageName';\n }\n\n $headers = [];\n if ($this->config->trigger_assignment_rules === false) {\n // @see: https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/headers_autoassign.htm\n $headers = [\n 'Sforce-Auto-Assign' => 'false',\n ];\n }\n\n $this->updateRecord($objectType, $objectId, [$objectStageType => $stage->name], $headers);\n }\n\n public function parseObjectType(string $objectId): string\n {\n if (Str::startsWith($objectId, '001')) {\n return 'account';\n }\n\n if (Str::startsWith($objectId, '003')) {\n return 'contact';\n }\n\n if (Str::startsWith($objectId, '00Q')) {\n return 'lead';\n }\n\n if (Str::startsWith($objectId, '006')) {\n return 'opportunity';\n }\n\n if (Str::startsWith($objectId, '00U')) {\n return 'event';\n }\n\n if (Str::startsWith($objectId, '00T')) {\n return 'task';\n }\n\n throw new \\InvalidArgumentException('Unsupported Object Type');\n }\n\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, ['profile' => $this->profile]);\n $query = $queryBuilder->buildGetUsersQuery($userToSearch);\n\n try {\n $salesforceUsers = $this->queryHandler->query($query, [\n 'active' => true,\n ]);\n } catch (NoResultsException $e) {\n $this->logger->info('[Salesforce] Sync Profiles. No users found', [\n 'query' => $query,\n 'error' => $e->getMessage(),\n ]);\n\n return null;\n }\n\n $teamRepository = app(TeamRepository::class);\n $customRules = $this->getCustomProfileRules($teamRepository);\n\n foreach ($salesforceUsers as $crmUser) {\n if ($crmUser['Email'] === null) {\n continue;\n }\n\n if (! $this->customProfileValidation($crmUser, $customRules)) {\n continue;\n }\n\n $user = $teamRepository->findActiveTeamMemberByEmail($this->team, $crmUser['Email']);\n\n if (! $user instanceof User) {\n continue;\n }\n\n $edition = $crmUser['UserPreferencesLightningExperiencePreferred']\n ? Profile::EDITION_LIGHTNING\n : Profile::EDITION_CLASSIC;\n\n $profileRepository = app(ProfileRepository::class);\n $profile = $profileRepository->updateOrCreateProfile(\n $user,\n [\n 'crm_configuration_id' => $this->config->getId(),\n 'crm_provider_id' => $crmUser['Id'],\n ],\n [\n 'user_id' => $user->getId(),\n 'edition' => $edition,\n 'has_external_cti' => ! empty($crmUser['CallCenterId']),\n 'crm_profile_id' => $crmUser['ProfileId'],\n ]\n );\n\n if ($userToSearch instanceof User && $userToSearch->getId() === $user->getId()) {\n return $profile;\n }\n }\n\n // Clean up inactive profiles\n try {\n $this->archiveInactiveProfiles();\n } catch (\\Exception $e) {\n $this->logger->warning('[Salesforce] Profile archiving failed', [\n 'teamId' => $this->team->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $url = null;\n\n // For Salesforce it's easy, we just point every object to the apex domain and they handle it.\n switch ($objectType) {\n case 'lead':\n case 'account':\n case 'contact':\n case 'opportunity':\n case 'task':\n case 'event':\n case 'activity':\n\n $url = $this->config->crm_base_url . '/' . $providerId;\n\n break;\n }\n\n return $url;\n }\n\n public function buildTaskSearchFields(): array\n {\n return ['Id', 'WhoId', 'WhatId', 'AccountId'];\n }\n\n public function getTaskByFilterConditions(\n array $fields,\n array $filters,\n bool $bulkSearch = false,\n bool $strictFilters = true\n ): ?array {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $query = $queryBuilder->buildSearchTaskQuery($fields, $filters, $bulkSearch, $strictFilters);\n\n try {\n if (! $bulkSearch) {\n $objects = $this->queryHandler->query($query, $filters);\n if ($objects->count() === 1) {\n return $objects->current();\n }\n }\n\n if ($bulkSearch) {\n $objects = $this->queryHandler->query($query);\n $records = [];\n foreach ($objects as $record) {\n $key = $record[end($fields)];\n $records[$key] = $record;\n }\n\n return $records;\n }\n } catch (\\Exception $e) {\n $this->logger->info('[Salesforce] Failed to execute query', [\n 'query' => $query,\n 'error' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function mapCrmObjects(array $task): array\n {\n $activityData = [];\n\n if (! empty($task['WhoId'])) {\n $type = $this->parseObjectType($task['WhoId']);\n $activityData[$type] = $task['WhoId'];\n }\n if (! empty($task['AccountId'])) {\n $activityData['account'] = $task['AccountId'];\n }\n if (! empty($task['WhatId'])) {\n $activityData['opportunity'] = $task['WhatId'];\n }\n\n return $activityData;\n }\n\n /**\n * Get SF task by Outreach call id.\n */\n public function getTaskByFilter(\n string $activityFieldType,\n array $filters,\n string $operator = '=',\n array $additionalFields = []\n ): ?array {\n $data = [];\n\n try {\n // Default (base) fields.\n $fields = ['Id', 'Subject', 'Description', 'ActivityDate', 'WhoId', 'WhatId', $activityFieldType];\n\n foreach ($additionalFields as $additionalField) {\n $fields[] = $additionalField->crm_provider_id;\n }\n\n $fields = array_unique($fields);\n\n // Find task with the same Outreach id as the call id.\n $query = 'SELECT ' . implode(',', $fields) . '\n FROM Task\n WHERE IsArchived = false AND IsDeleted = false';\n\n foreach ($filters as $key => $value) {\n $key = preg_quote($key, '/');\n $key = str_replace(['\\'', '\"'], '', $key);\n // Prepare the substitution.\n $strKey = \":$key\";\n\n $query .= \" AND $key $operator $strKey\";\n }\n\n $query .= ' ORDER BY LastModifiedDate DESC LIMIT 1';\n\n $objects = $this->queryHandler->query($query, $filters);\n\n // There should be only one task related to this call if any.\n if ($objects->count() === 1) {\n $object = $objects->current();\n\n $dueDate = $object['ActivityDate'] ? Carbon::parse($object['ActivityDate'])->toIso8601String() : null;\n\n $data = array_merge($object, [\n 'crmId' => $object['Id'],\n 'subject' => $object['Subject'],\n 'summary' => $object['Description'],\n 'due' => $dueDate,\n 'Type' => $object[$activityFieldType],\n ]);\n }\n } catch (NoResultsException $e) {\n // Filters don't match any records.\n } catch (ServiceUnavailableException $serviceUnavailableException) {\n // Service cannot be queried. We should probably log this.\n }\n\n return $data;\n }\n\n /**\n * Get Salesforce fields including datetime fields\n *\n * @param $objectType\n */\n private function getAllFieldsAsArray($objectType): array\n {\n $basicFields = [];\n // Not all users have access to all object fields.\n if ($this->profile->{$objectType . '_fields'}) {\n $basicFields = explode(',', $this->profile->{$objectType . '_fields'});\n }\n\n $extraFields = [\n 'CreatedDate',\n 'LastModifiedDate',\n 'IsDeleted',\n ];\n\n if ($objectType === self::OBJECT_OPPORTUNITY\n && $this->config->opportunity_value_field_id\n && ! in_array($this->config->opportunityValueField->crm_provider_id, $basicFields)\n ) {\n $extraFields[] = $this->config->opportunityValueField->crm_provider_id;\n }\n\n return array_unique(array_merge($basicFields, $extraFields));\n }\n\n /**\n * Generate transcription for activity description.\n */\n private function generateTranscription(Activity $activity): string\n {\n if (! ($this->config->store_transcript)) {\n // If sending transcription to activity toggle is disabled\n return '';\n }\n\n return $this->transcriptionService\n ->findTranscriptionByActivity($activity)\n ->map(static function (array $transcriptionSegment): string {\n return $transcriptionSegment['formattedStartsAt'] . ' | ' . $transcriptionSegment['transcript'];\n })\n ->implode(PHP_EOL);\n }\n\n /**\n * Find related Salesforce event based on activity data\n *\n * @return array<string>\n */\n public function fetchRelatedActivity(Activity $activity): array\n {\n $this->logger->info('[Salesforce] Searching for related activity', [\n 'activityId' => $activity->getUuid(),\n 'ownerId' => $this->profile?->crm_provider_id,\n ]);\n\n $sfEvent = $this->fetchRelatedEvent($activity);\n if (empty($sfEvent)) {\n $this->logger->info('[Salesforce] No related activity found', [\n 'activityId' => $activity->getUuid(),\n 'ownerId' => $this->profile?->crm_provider_id,\n 'account' => $activity->hasAccount()\n ? $activity->getAccount()->getCrmProviderId()\n : null,\n ]);\n\n return [];\n }\n\n return $sfEvent;\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n if ($activity->isTypeConference() === false) {\n return null;\n }\n\n if ($activity->hasActualStartTime() === false && $activity->hasScheduledStartTime() === false) {\n return null;\n }\n\n if (! $activity->hasProspect()) {\n $this->logger->info('[Salesforce] Skip look up, Activity not linked to Lead, Contact or Account', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n $playbook = $this->getPlaybook($activity->getUser());\n if ($playbook !== null && $playbook->getActivityType() === Playbook::ACTIVITY_TYPE_TASK) {\n $this->logger->info('[Salesforce] Skip auto-sync for task-based playbook', [\n 'activityUuid' => $activity->getUuid(),\n 'playbookId' => $playbook->getId(),\n 'playbookType' => $playbook->getActivityType(),\n ]);\n\n return null;\n }\n\n try {\n $sfEvent = $this->fetchRelatedActivity($activity);\n if (empty($sfEvent)) {\n return null;\n }\n\n [$activityField, $activityType] = $this->resolveActivityTypeFromEvent($activity, $sfEvent);\n\n $this->logger->info('[Salesforce] Found related activity', [\n 'activityId' => $activity->getUuid(),\n 'sfEvent' => $sfEvent['Id'],\n 'activityFieldName' => $activityField,\n 'crmActivityType' => ($activityField !== null && isset($sfEvent[$activityField]))\n ? $sfEvent[$activityField]\n : null,\n 'activityType' => $activityType,\n ]);\n\n $userId = $this->findRelatedActivityUserId($activity, $sfEvent);\n\n if ($activity->getUserId() !== $userId) {\n $this->logger->info('[Salesforce] Updating meeting owner', [\n 'activityId' => $activity->getUuid(),\n 'oldUserId' => $activity->getUserId(),\n 'newUserId' => $userId,\n ]);\n }\n\n $this->updateSfEventDescription($activity, $sfEvent);\n\n $activity->update([\n 'user_id' => $userId,\n 'crm_provider_id' => $sfEvent['Id'],\n 'playbook_category_id' => $activityType->id ?? $activity->getCategory()?->getId(),\n ]);\n\n $this->logger->info('[Salesforce] Activity updated', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return $activity;\n } catch (\\Exception $exception) {\n \\Sentry::captureException($exception);\n\n throw $exception;\n }\n }\n\n /**\n * @param array<string, mixed> $sfEvent\n *\n * @return array{0: string|null, 1: mixed}\n */\n private function resolveActivityTypeFromEvent(Activity $activity, array $sfEvent): array\n {\n $activityField = $this->getActivityFieldName($activity);\n $activityType = null;\n\n if ($activityField !== null && ! empty($sfEvent[$activityField])) {\n $playbook = $this->getPlaybook($activity->getUser());\n $activityType = $this->getPlaybookCategory($playbook, strval($sfEvent[$activityField]));\n }\n\n return [$activityField, $activityType];\n }\n\n /**\n * @param array<string> $sfEvent\n */\n private function findRelatedActivityUserId(Activity $activity, array $sfEvent): int\n {\n $userId = $activity->getUserId();\n\n if (empty($sfEvent['OwnerId']) === false) {\n $profile = $this\n ->config\n ->profiles()\n ->where('crm_provider_id', $sfEvent['OwnerId'])\n ->get()\n ->filter(static function (Profile $profile) use ($activity): bool {\n if (! $activity->isTypeConference()) {\n return ! empty($profile->user) ? $profile->user->isStatusActive() : false;\n }\n\n $participants = $activity->getParticipants();\n\n return ! empty($profile->user)\n ? $profile->user->isStatusActive()\n && $profile->user->hasPermission(PermissionEnum::RECORD_MEETING)\n && $participants->contains('user_id', $profile->user_id)\n : false;\n })\n ->first();\n\n if ($profile) {\n $userId = $profile->user_id;\n }\n }\n\n return $userId;\n }\n\n /**\n * @param array<string, mixed> $sfEvent\n */\n private function updateSfEventDescription(Activity $activity, array $sfEvent): void\n {\n try {\n if (str_contains($sfEvent['Description'], $activity->id_string)) {\n return;\n }\n\n $payload = [\n 'Description' => $sfEvent['Description']\n . PHP_EOL\n . PHP_EOL\n . (new DecorateActivity())->generateDescription($activity),\n ];\n\n $this->logger->info('[Salesforce] Update record', [\n 'activityId' => $activity->getUuid(),\n 'sfEvent' => $sfEvent['Id'],\n 'payload' => $payload,\n ]);\n\n $payload = array_merge(\n $payload,\n $this->payloadBuilder->fetchCustomFieldData($activity, Field::OBJECT_EVENT)\n );\n\n $this->updateRecord('Event', $sfEvent['Id'], $payload);\n } catch (\\Exception) {\n $this->logger->error('[Salesforce] Failed to update record', [\n 'activityUuid' => $activity->getUuid(),\n 'sfEvent' => $sfEvent['Id'],\n ]);\n }\n }\n\n /**\n * Returns the most recently modified Event within time range (if any).\n *\n * @return array|null An Event record from Salesforce.\n */\n private function fetchRelatedEvent(Activity $activity): ?array\n {\n $ownerId = $this->profile?->crm_provider_id;\n if ($ownerId === null) {\n return [];\n }\n\n /** @var ?Carbon $from */\n /** @var ?Carbon $to */\n [$from, $to] = $this->getFromToDates($activity);\n\n try {\n $whoId = null;\n $hasWho = $activity->lead_id || $activity->contact_id;\n if ($hasWho) {\n $whoId = $activity->hasLead()\n ? $activity->getLead()->crm_provider_id\n : $activity->getContact()->crm_provider_id;\n }\n\n if ($hasWho === false && $activity->account_id === null) {\n return null;\n }\n\n $query = $this->buildFetchRelatedEventQuery($activity);\n\n $objects = $this->queryHandler->query($query, [\n 'ownerId' => $ownerId,\n 'whoId' => $whoId,\n 'whatId' => $activity->hasOpportunity() ? $activity->getOpportunity()->crm_provider_id : null,\n 'accountId' => $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null,\n 'from' => $from?->format('Y-m-d\\TH:i:s\\Z'),\n 'to' => $to?->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($objects as $object) {\n return $object;\n }\n } catch (NoResultsException $e) {\n return [];\n }\n\n return [];\n }\n\n private function getFromToDates(Activity $activity): array\n {\n $from = null;\n $to = null;\n\n /** @var ?CalendarEvent $calendarEvent */\n $calendarEvent = $activity->calendarEvent()->first();\n if ($calendarEvent !== null) {\n $from = $calendarEvent->getStartTime();\n $to = $calendarEvent->getEndTime();\n }\n\n // For non-calendar imported activities\n // Also double check if calendar event dates could be null?\n // If null use what we've got so far\n if ($from === null || $to === null) {\n $from = $activity->hasScheduledStartTime()\n ? $activity->getScheduledStartTime()\n : $activity->getActualStartTime();\n $to = $activity->hasScheduledEndTime()\n ? $activity->getScheduledEndTime()->addMinutes(15)\n : $activity->getActualEndTime();\n }\n\n return [$from, $to];\n }\n\n /**\n * Determines the appropriate activity field name for querying Salesforce events.\n *\n * This method follows a hierarchy to determine the field name:\n * 1. Uses the playbook's activity field if it exists and is in the profile's accessible fields\n * 2. Falls back to the default activity field if the profile has no event fields configured\n * 3. Returns null if no suitable field is found\n *\n * @param Activity $activity The activity to determine the field for\n *\n * @return string|null The field name to use in queries, or null if none is available\n */\n private function getActivityFieldName(Activity $activity): ?string\n {\n if ($this->profile === null) {\n $this->logger->warning('[Salesforce] Cannot determine activity field - profile not found', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n $profileEventFields = $this->profile->getFieldsAsArray('event');\n\n if (empty($profileEventFields)) {\n $defaultActivityField = $this->getDefaultActivityField(Field::OBJECT_EVENT);\n $defaultFieldName = $defaultActivityField?->getAttribute('crm_provider_id');\n // Profile not yet synced — fall back to the default activity field.\n // There is a small chance that the profile won't have Default Activity Type field access\n // in which case the query will fail.\n // This is however an edge case and should be reviewed for profile sync issues.\n Sentry::withScope(function (\\Sentry\\State\\Scope $scope) use ($defaultFieldName): void {\n $scope->setContext('details', [\n 'profileId' => $this->profile->id,\n 'defaultField' => $defaultFieldName,\n ]);\n Sentry::captureMessage(\n '[Salesforce] Profile event fields empty, falling back to default activity field.',\n \\Sentry\\Severity::warning()\n );\n });\n\n return $defaultFieldName;\n }\n\n $playbook = $this->getPlaybook($activity->getUser());\n\n if (! is_null($playbook) && ! is_null($playbook->getActivityField())) {\n $playbookFieldName = $playbook->getActivityField()->getAttribute('crm_provider_id');\n\n if (in_array($playbookFieldName, $profileEventFields, true)) {\n return $playbookFieldName;\n }\n\n $this->logger->warning('[Salesforce] Playbook activity field not found in profile fields', [\n 'activityId' => $activity->getUuid(),\n 'playbookField' => $playbookFieldName,\n 'profileId' => $this->profile->id,\n ]);\n }\n\n return null;\n }\n\n private function buildFetchRelatedEventQuery(Activity $activity): string\n {\n $hasWho = $activity->lead_id || $activity->contact_id;\n\n $activityFieldName = $this->getActivityFieldName($activity);\n $fields = array_filter(['Id', 'Description', 'OwnerId', $activityFieldName]);\n\n $ownerCondition = '(OwnerId = :ownerId OR CreatedById = :ownerId)';\n\n $query = '\n SELECT ' . implode(',', $fields) . '\n FROM Event\n WHERE ' . $ownerCondition . '\n AND IsArchived = false\n AND IsAllDayEvent = false\n AND StartDateTime >= :from\n AND EndDateTime <= :to\n AND (';\n\n $operator = '';\n if ($activity->account_id) {\n // This covers events tied to a related contact or opportunity too.\n $query .= 'AccountId = :accountId';\n\n $operator = ' OR ';\n }\n\n if ($hasWho) {\n $query .= $operator . 'WhoId = :whoId';\n\n // If we are also going to check on a specific opportunity, set that up.\n if ($activity->opportunity_id) {\n $query .= ' OR WhatId = :whatId';\n }\n }\n\n $query .= ') ORDER BY LastModifiedDate DESC';\n\n return $query;\n }\n\n public function fetchProspect(array $task): array\n {\n $lead = $account = $opportunity = $contact = $stage = $countryCode = null;\n $externalId = $task['WhoId'] ?? null;\n\n // Lead or Contact\n if ($externalId) {\n try {\n [$lead, $account, $opportunity, $contact, $stage, $countryCode] = $this->parseRecords($externalId);\n } catch (\\InvalidArgumentException $exception) {\n // Invalid object type.\n }\n }\n\n // If we happen to know the opportunity or account from the Task, figure that out.\n if (empty($task['WhatId']) === false) {\n // WhatId could be either Account ID or Opportunity ID.\n // If WhatId is Opportunity ID, get the opportunity and stage from the CRM.\n try {\n [, $account, $opportunity, , $stage, ] = $this->parseRecords($task['WhatId']);\n } catch (\\InvalidArgumentException $exception) {\n // Invalid object type.\n }\n }\n\n return [$lead, $account, $opportunity, $contact, $stage, $countryCode];\n }\n\n /**\n * Save activity transcription summary as note\n */\n public function saveTranscriptionSummaryAsNote(\n ActivityContract $activity,\n string $title,\n string $body,\n ?string $objectId,\n ?NoteObject $noteObject = null,\n ): ?string {\n return $this->saveNote($title, $body, (string) $objectId);\n }\n\n public function getObjectByFilterConditions(string $objectType, array $fields, array $filters): ?array\n {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $query = $queryBuilder->buildObjectSearchQuery($objectType, $fields, $filters);\n\n try {\n $objects = $this->queryHandler->query($query, $filters);\n if ($objects->count() === 1) {\n return $objects->current();\n }\n } catch (\\Exception $e) {\n $this->logger->info('[Salesforce] Failed to execute query', [\n 'query' => $query,\n 'error' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n private function getCustomProfileRules(TeamRepository $teamRepository): array\n {\n $teamSettings = $teamRepository->getTeamSetting($this->team, 'custom_profile_validation');\n\n if ($teamSettings instanceof TeamSettings && $teamSettings->getValueType() === 'array') {\n $customRules = json_decode($teamSettings->getValue(), true);\n if (is_array($customRules)) {\n return $customRules;\n }\n }\n\n return [];\n }\n\n private function customProfileValidation(array $crmUser, array $customRules): bool\n {\n foreach ($customRules as $customRule) {\n if ($crmUser[$customRule['field']] !== $customRule['value']) {\n return false;\n }\n }\n\n return true;\n }\n\n /**\n * When syncing Contact / Lead / Account / Opportunity / Stage crm entities,\n * validate and restore locally trashed objects,\n * before updating them. Objects are identified by CrmProviderId\n */\n private function restoreAnyTrashedEntity(HasMany $targetEntity, string $crmProviderId): void\n {\n $recordExists = $targetEntity->withTrashed()->where(['crm_provider_id' => $crmProviderId])->first();\n if ($recordExists && $recordExists->trashed()) {\n $recordExists->restore();\n }\n }\n\n #[\\Override] public function supportsNotes(): bool\n {\n return true;\n }\n\n private function getOwnerProfile(?string $ownerId): ?Profile\n {\n if ($ownerId === null) {\n return null;\n }\n\n return $this->config->profiles()\n ->where('crm_provider_id', $ownerId)\n ->first();\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Help","depth":1,"bounds":{"left":0.25531915,"top":0.65363127,"width":0.00930851,"height":0.027134877},"on_screen":true,"help_text":"Show help contents","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Cancel","depth":1,"bounds":{"left":0.5142952,"top":0.65363127,"width":0.025930852,"height":0.027134877},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Commit","depth":1,"bounds":{"left":0.5422208,"top":0.65363127,"width":0.03756649,"height":0.027134877},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Commit","depth":2,"bounds":{"left":0.5422208,"top":0.65363127,"width":0.027260639,"height":0.027134877},"on_screen":true,"help_text":"Show drop-down menu (⌥⇧⏎)","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Commit Changes","depth":1,"bounds":{"left":0.39827126,"top":0.13248204,"width":0.038231384,"height":0.012769354},"on_screen":true,"role_description":"text"}]...
|
-4667806593135088920
|
-7851939513083163707
|
typing_pause
|
accessibility
|
NULL
|
Diff
Changelist:
Changelist:
Changes
Show Diff
Rol Diff
Changelist:
Changelist:
Changes
Show Diff
Rollback...
Refresh
Group By
Expand All
Collapse All
1 modified
JY-20
Commit Message
Commit Message History
Git
Author:
Author:
Amend commit
Sign-off commit
Create extra commit with file movements
Commit Checks
Update copyright
Reformat code
Rearrange code
Optimize imports
Cleanup
Choose profile
Check malicious dependencies
Run external formatter
Configure
Perform SonarQube for IDE analysis
Find unused view files
Run Git hooks
Advanced Commit Checks
Analyze code
Choose profile
Check TODO
Configure
After Commit
Upload files to:
Upload files to:
<None>
Always use selected server or group of servers
Previous Difference
Next Difference
Jump to Source
Compare Previous File
Compare Next File
Go to Changed File…
Side-by-side viewer
Do not ignore
Highlight words
Collapse Unchanged Fragments
Synchronize Scrolling
Disable Editing
Settings
Help
4 differences
1aee7aad
<?php
namespace Jiminny\Services\Crm\Salesforce;
use Carbon\Carbon;
use Exception;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Events\Dispatcher;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Jiminny\Component\Country\CountriesMap;
use Jiminny\Contracts\Acl\PermissionEnum;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Contracts\Services\Crm\FetchRelatedActivityInterface;
use Jiminny\Contracts\Services\Crm\ImportsBusinessProcessesInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\SalesforceBatchSyncInterface;
use Jiminny\Contracts\Services\Crm\Provider\SalesforceInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\RemoteNoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SearchTaskInterface;
use Jiminny\Contracts\Services\Crm\SendSummaryToCrmInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SupportsObjectTypeParseInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmProfileRecordTypesInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Enums\CrmObject;
use Jiminny\Events\Activities\Crm\LeadConverted;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpBadRequestException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Exceptions\NoResultsException;
use Jiminny\Exceptions\ServiceUnavailableException;
use Jiminny\Jobs\Crm\NoteObject;
use Jiminny\Jobs\Crm\MatchActivitiesToNewOpportunity;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Calendar\CalendarEvent;
use Jiminny\Models\Contact;
use Jiminny\Models\Contracts\ActivityContract;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Models\Crm\ContactRole;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Crm\RecordType;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Playbook;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\TeamSettings;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\ContactRoleRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\Crm\RecordTypeFieldValuesRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Helpers\ArrayIterator;
use Jiminny\Services\Crm\MatchDomainByEmailInterface;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Services\Crm\ResolveCompanyNameByEmailTrait;
use Jiminny\Services\Crm\Salesforce\Fields\FieldHelper;
use Jiminny\Services\Crm\Salesforce\Fields\FieldTypeConverter;
use Jiminny\Services\Crm\Salesforce\Fields\ValueNormalizer;
use Jiminny\Services\Crm\Salesforce\ServiceTraits\FollowupActivityTrait;
use Jiminny\Services\Crm\Salesforce\ServiceTraits\LogActivityTrait;
use Jiminny\Services\Crm\Salesforce\ServiceTraits\RecordManipulationsTrait;
use Jiminny\Services\Crm\Salesforce\ServiceTraits\SyncFieldsTrait;
use Jiminny\Utils\CurrencyFormatter;
use Jiminny\Utils\StringUtil;
use Ramsey\Uuid\Uuid;
use Sentry\Laravel\Facade as Sentry;
class Service extends BaseService implements
SalesforceInterface,
SalesforceBatchSyncInterface,
SyncCrmEntitiesInterface,
SyncCrmProfileRecordTypesInterface,
ImportsBusinessProcessesInterface,
RemoteEntityManipulationInterface,
FetchRelatedActivityInterface,
SendSummaryToCrmInterface,
MatchDomainByEmailInterface,
SearchTaskInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
SupportsObjectTypeParseInterface,
RemoteNoteEntityManipulationInterface,
VerifyTaskExistsInterface
{
use ResolveCompanyNameByEmailTrait;
use SyncFieldsTrait;
use DeleteObjectsTrait;
use RecordManipulationsTrait;
use ServiceTraits\BatchSyncTrait;
use FollowupActivityTrait;
use LogActivityTrait;
/**
* Note Body Limit for the Old Note-Taking Tool
*
* @var int
*/
private const int CLASSIC_NOTE_MAX_LENGTH = 32000;
/**
* Note Content Limit for the New Notes
*
* @var int
*/
private const int ENHANCED_NOTE_MAX_LENGTH = 50000000;
private const string INSTALLED_PACKAGE_ID = '033Tw0000007bKbIAI';
private const int CACHE_TTL = 600;
private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day - 86400
/**
* @var Client
*/
protected $client;
protected PayloadBuilder $payloadBuilder;
protected QueryHandler $queryHandler;
private OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
public function __construct(
Client $client,
PayloadBuilder $payloadBuilder,
protected Dispatcher $eventDispatcher,
private readonly CountriesMap $countriesMap,
private readonly ProspectPhotoPathService $prospectPhotoPathService,
) {
parent::__construct();
$this->client = $client;
$this->payloadBuilder = $payloadBuilder;
$this->queryHandler = app(QueryHandler::class, [
'client' => $this->client,
'logger' => $this->logger,
]);
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
}
public function getDisplayName(): string
{
return 'Salesforce';
}
public function getJobDelay(): int
{
return 1;
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
return $user->getSocialAccount(SocialAccount::PROVIDER_SALESFORCE);
}
public function verifyTaskExists(Activity $activity): bool
{
$crmProviderId = $activity->getCrmProviderId();
$cacheKey = "crm_task_exists:{$this->config->getId()}:$crmProviderId";
return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($activity, $crmProviderId) {
$playbook = $this->getPlaybookFromActivity($activity);
if ($playbook === null) {
$this->logger->warning('[Salesforce] Cannot verify task - no playbook found', [
'activity' => $activity->getId(),
'crm_provider_id' => $crmProviderId,
]);
return false;
}
$objectType = $playbook->getActivityType() === Playbook::ACTIVITY_TYPE_EVENT ? 'Event' : 'Task';
try {
$record = $this->getRecord($objectType, $crmProviderId, ['Id', 'IsDeleted']);
return ! empty($record) && ($record['IsDeleted'] ?? false) === false;
} catch (HttpNotFoundException|HttpBadRequestException) {
$this->logger->info('[Salesforce] Activity record not found during verification', [
'activity' => $activity->getId(),
'object_type' => $objectType,
'crm_provider_id' => $crmProviderId,
'config_id' => $this->config->getId(),
]);
return false;
}
});
}
public function query(string $queryToRun, array $parameters = []): QueryIterator
{
// Due to poorly designed external calls, this method cannot be entirely removed
return $this->queryHandler->query($queryToRun, $parameters);
}
/*=========== Organization Information ===============*/
/**
* Get a list of all the API Versions for the instance.
*
* @throws CrmException
*
* @return mixed
*
*/
public function getApiVersions()
{
$url = $this->config->crm_base_url . '/services/data';
$response = $this->client->get($url);
return json_decode($response->getBody(), true);
}
/**
* Gets the valid recordTypes for a given Salesforce Object via the describe API.
*/
private function getRecordTypes(string $crmObject): array
{
$url = $this->client->getObjectsUrl() . $crmObject . '/describe';
$response = $this->client->get($url);
$jsonResponse = json_decode($response->getBody(), true);
$fields = [];
foreach ($jsonResponse['recordTypeInfos'] as $row) {
$fields[] = ['recordTypeId' => $row['recordTypeId'], 'default' => $row['defaultRecordTypeMapping']];
}
return $fields;
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return ValueNormalizer::normalize($fieldType, $fieldValue, $internal);
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
$defaultFields = ($activityType === Playbook::ACTIVITY_TYPE_TASK)
? FieldDefinitions::defaultTaskFields()
: FieldDefinitions::defaultEventFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
// Setup the activity field as the default Type.
/** @var Field $activityField */
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'Type',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK, Playbook::ACTIVITY_TYPE_EVENT];
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldFilter = ($activityType === Playbook::ACTIVITY_TYPE_TASK)
? FieldDefinitions::taskFollowupFieldsFilter()
: FieldDefinitions::eventFollowupFieldsFilter();
foreach ($fieldFilter as $eachFilter) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $eachFilter);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
/**
* This one is now called only when ImportActivityTypes is triggered or SyncFieldMetadata executed manually
* Regular sync now uses SharedSyncFieldsTrait -> syncSingleObjectType
* Needs to be replaced later on
*/
public function syncField(Field $field): void
{
try {
if (FieldHelper::isCustomField($field)) {
$query = '
SELECT
Id, Metadata, TableEnumOrId
FROM
CustomField
WHERE
DeveloperName = :fieldName
AND
TableEnumOrId = :fieldType
AND
NamespacePrefix = :namespacePrefix';
// We need to constrain the field lookup to the object, in case it's used in multiple places.
$objectType = \in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true)
? 'activity'
: $field->object_type;
$sfFields = $this->queryHandler->metadata($query, [
'fieldName' => substr($field->crm_provider_id, 0, -\strlen('__c')),
'fieldType' => ucfirst($objectType),
// This is used to ensure we only consider the field within the org, not installed packages.
'namespacePrefix' => 'null',
]);
// There is always 1 result at this point.
$sfField = $sfFields->current();
// Sync field metadata.
$metadata = $sfField['Metadata'];
$field->description = mb_strimwidth($metadata['description'] ?? '', 0, 191);
$field->label = mb_strimwidth($metadata['label'] ?? '', 0, Field::LABEL_MAX_LENGTH);
$field->type = $this->convertFieldType($metadata['type'], $field->getEntityName());
$field->is_mandatory = ($metadata['required'] === true);
$field->length = $metadata['length'];
$field->default_value = mb_strimwidth(trim($metadata['defaultValue'] ?? '', '"'), 0, 191);
$field->save();
} else {
$query = '
SELECT
Id, DataType, DeveloperName, Label, Length, Description
FROM
FieldDefinition
WHERE
DurableId = :entityName';
$entityName = $field->getEntityName();
$sfFields = $this->queryHandler->metadata($query, [
'entityName' => $entityName,
]);
// There is always 1 result at this point.
$sfField = $sfFields->current();
$convertedType = $this->convertFieldType($sfField['DataType'], $entityName);
$label = mb_strimwidth($sfField['Label'], 0, Field::LABEL_MAX_LENGTH);
if ($field->isBusinessType()) {
$label = 'Opportunity Type';
}
$field->description = mb_strimwidth($sfField['Description'], 0, Field::DESCRIPTION_MAX_LENGTH);
$field->label = $label;
$field->type = $convertedType;
$field->length = $sfField['Length'];
$field->save();
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
}
private function convertFieldType(string $from, ?string $entityName = null): string
{
$converter = new FieldTypeConverter();
return $converter->convert($from, $entityName);
}
/**
* @inheritdoc
*/
public function importPicklistValues(Field $field): array
{
$values = [];
$fieldValues = [];
try {
if (FieldHelper::isCustomField($field)) {
$query = '
SELECT
Id, Metadata, TableEnumOrId
FROM
CustomField
WHERE
DeveloperName = :fieldName
AND
TableEnumOrId = :fieldType
AND
NamespacePrefix = :namespacePrefix';
// We need to constrain the field lookup to the object, in case it's used in multiple places.
$objectType = \in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true) ?
'activity' : $field->object_type;
$sfFields = $this->queryHandler->metadata($query, [
'fieldName' => substr($field->crm_provider_id, 0, -\strlen('__c')),
'fieldType' => ucfirst($objectType),
// This is used to ensure we only consider the field within the org, not installed packages.
'namespacePrefix' => 'null',
]);
// There is always 1 result at this point.
$sfField = $sfFields->current();
$valueSet = $sfField['Metadata']['valueSet'];
if ($valueSet['valueSetName'] === null) {
// Local picklist values can be obtained easily.
$picklistValues = $valueSet['valueSetDefinition']['value'];
} else {
// But for some fields, we just get the Global Value Picklist pointer so need to do more work.
$picklistValues = $this->importGlobalValuePicklistValues($valueSet['valueSetName']);
}
// Import all active values.
foreach ($picklistValues as $i => $sfFieldValue) {
// Setup default value.
if ($sfFieldValue['default']) {
$field->update(['default_value' => $sfFieldValue['valueName']]);
}
// This comes through as null if active (lol).
if ($sfFieldValue['isActive'] !== false) {
$values[] = [
'value' => $sfFieldValue['valueName'],
'label' => $sfFieldValue['valueName'],
'sequence' => $i,
'is_default' => $sfFieldValue['default'],
];
}
}
} else {
$objectFields = $this->getObjectFields($field->object_type);
$fieldId = $field->crm_provider_id;
// Only work with our field of interest.
$objectField = array_filter($objectFields, function ($item) use ($fieldId) {
return $item['name'] === $fieldId;
});
$objectField = array_shift($objectField);
if (empty($objectField['picklistValues']) === false) {
foreach ($objectField['picklistValues'] as $i => $sfFieldValue) {
// Skip inactive values.
if ($sfFieldValue['active'] === false) {
continue;
}
// Setup default value.
if ($sfFieldValue['defaultValue']) {
$field->update(['default_value' => $sfFieldValue['value']]);
}
$values[] = [
'value' => $sfFieldValue['value'],
'label' => $sfFieldValue['label'],
'sequence' => $i,
'is_default' => $sfFieldValue['defaultValue'],
];
}
}
}
$fieldsToPurge = $field->values()->get()->pluck('value')->toArray();
foreach ($values as $value) {
$value['value'] = substr($value['value'] ?? '', 0, 255);
$fieldValues[] = $field->values()->updateOrCreate([
'value' => $value['value'],
], $value);
// Remove this value from the ones we are going to purge.
if (($key = array_search($value['value'], $fieldsToPurge, true)) !== false) {
unset($fieldsToPurge[$key]);
}
}
// Delete the old values that are no longer used.
// Get IDs of the values to be deleted
$valuesToDelete = $field->values()->whereIn('value', $fieldsToPurge);
$valuesToDeleteIds = $valuesToDelete->pluck('id');
if (! $valuesToDeleteIds->isEmpty()) {
$recordTypeFieldValuesRepository = app(RecordTypeFieldValuesRepository::class);
$recordTypeFieldValuesRepository->deleteForCrmFieldValueIds($valuesToDeleteIds->toArray());
// Now safely delete from crm_field_values
$valuesToDelete->delete();
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
return $fieldValues;
}
/**
* Gets values from Global Value Picklists.
*/
private function importGlobalValuePicklistValues(string $picklistName): array
{
$query = '
SELECT
Metadata
FROM
GlobalValueSet
WHERE
DeveloperName = :picklistName
LIMIT 1';
try {
$sfValues = $this->queryHandler->metadata($query, [
'picklistName' => $picklistName,
]);
// There is always 1 result at this point.
$sfValue = $sfValues->current();
return $sfValue['Metadata']['customValue'];
} catch (NoResultsException $noResultsException) {
// Nothing returned.
return [];
}
}
/**
* @inheritdoc
*/
public function syncProfileRecordTypes(): void
{
$objectTypes = [
'lead',
'account',
'contact',
'opportunity',
'task',
'event',
];
foreach ($objectTypes as $objectType) {
try {
$crmRecordTypes = $this->getRecordTypes(ucfirst($objectType));
foreach ($crmRecordTypes as $crmRecordType) {
// If the record type is default and not the Master type, set this.
if ($crmRecordType['default'] && $crmRecordType['recordTypeId'] !== '012000000000000AAA') {
$recordType = $this->config->recordTypes()
->where('crm_provider_id', $crmRecordType['recordTypeId'])
->first();
if ($recordType) {
$this->profile->{$objectType . '_record_type_id'} = $recordType->id;
}
}
}
} catch (HttpNotFoundException $exception) {
Log::error('No access to ' . $objectType . ' object, skipping...');
// XXX: should we log this fact somewhere?
continue;
}
}
if ($this->profile->isDirty()) {
$this->profile->save();
}
}
/**
* Gets business processes.
*/
public function importBusinessProcesses(): void
{
$query = '
SELECT
Id, IsActive, Name, TableEnumOrId
FROM
BusinessProcess
WHERE
TableEnumOrId IN (\'Lead\',\'Opportunity\')';
try {
$sfProcesses = $this->queryHandler->query($query);
// Upsert all processes for the team.
foreach ($sfProcesses as $sfProcess) {
/** @var BusinessProcess $businessProcess */
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => $sfProcess['Id'],
], [
'team_id' => $this->team->id,
'name' => $sfProcess['Name'],
'type' => $sfProcess['TableEnumOrId'] === 'Lead' ? 'lead' : 'opportunity',
'is_selectable' => $sfProcess['IsActive'],
]);
$this->importBusinessProcessStages($businessProcess);
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
}
/**
* Gets business process stages.
*/
private function importBusinessProcessStages(BusinessProcess $businessProcess): void
{
$query = '
SELECT
Metadata
FROM
BusinessProcess
WHERE
Id = :processId';
try {
$stages = [];
$sfProcessStages = $this->queryHandler->metadata($query, [
'processId' => $businessProcess->crm_provider_id,
]);
// There is always 1 result at this point.
$sfProcessStage = $sfProcessStages->current();
// Upsert all processes for the team.
foreach ($sfProcessStage['Metadata']['values'] as $sfProcessStage) {
$sanitizedName = urldecode($sfProcessStage['valueName']); // Must decode: "%2C" becomes "," etc.
$stage = $businessProcess->crm->stages()
// This MUST match on label because this API doesn't use API Name.
->where('label', $sanitizedName)
->where('type', $businessProcess->type)
->where('is_selectable', 1)
->first();
if ($stage) {
$stages[] = $stage->id;
}
}
$businessProcess->stages()->sync($stages);
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
}
/**
* Gets record types.
*/
public function importRecordTypes(): void
{
$query = '
SELECT
Id, IsActive, Name, BusinessProcessId, SobjectType
FROM
RecordType';
try {
$sfRecordTypes = $this->queryHandler->query($query);
// Upsert all record types for the process.
foreach ($sfRecordTypes as $sfRecordType) {
$businessProcess = null;
if ($sfRecordType['BusinessProcessId']) {
$businessProcess = $this->config->businessProcesses()
->where('crm_provider_id', $sfRecordType['BusinessProcessId'])
->first();
}
/** @var RecordType $recordType */
$recordType = $this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => $sfRecordType['Id'],
], [
'team_id' => $this->team->id,
'type' => mb_strtolower($sfRecordType['SobjectType']),
'name' => $sfRecordType['Name'],
'is_selectable' => $sfRecordType['IsActive'],
'business_process_id' => $businessProcess->id ?? null,
]);
$this->importRecordTypeFieldValues($recordType);
}
} catch (NoResultsException $noResultsException) {
// Do nothing.
}
}
/**
* Import record type - field value mappings. This only works for standard fields.
*/
private function importRecordTypeFieldValues(RecordType $recordType): void
{
try {
$query = '
SELECT
Metadata
FROM
RecordType
WHERE
Id = :recordTypeId';
$sfFields = $this->queryHandler->metadata($query, [
'recordTypeId' => $recordType->crm_provider_id,
]);
// There is always 1 result at this point.
$sfField = $sfFields->current();
// Sync field metadata.
$picklists = $sfField['Metadata']['picklistValues'];
foreach ($picklists as $picklist) {
$field = $this->config->fields()->where([
'type' => Field::TYPE_PICKLIST,
'object_type' => $recordType->type,
'crm_provider_id' => $picklist['picklist'],
])->first();
if ($field) {
$fieldValues = [];
foreach ($picklist['values'] as $value) {
// Must decode: "%2C" becomes "," etc.
$fieldValue = $field->values()
->where('value', urldecode($value['valueName']))
->first();
if ($fieldValue) {
$fieldValues[] = $fieldValue->id;
}
}
$recordType->fieldValues()->sync($fieldValues);
}
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
}
/**
* @inheritdoc
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$params = [];
$missingStage = null;
if ($types === null) {
$types = [Stage::TYPE_LEAD, Stage::TYPE_OPPORTUNITY];
}
foreach ($types as $type) {
if ($type === Stage::TYPE_LEAD) {
$query = '
SELECT
Id, ApiName, MasterLabel, SortOrder
FROM
LeadStatus';
} else {
$query = '
SELECT
Id, ApiName, MasterLabel, IsActive, SortOrder, DefaultProbability
FROM
OpportunityStage';
}
if ($missingStageName) {
$escapedStageName = ValueNormalizer::replaceQueryWithStringLiterals($missingStageName);
$query .= ' WHERE ApiName = :stageName';
$params = [
'stageName' => $escapedStageName,
];
}
try {
$sfStages = $this->queryHandler->query($query, $params);
} catch (NoResultsException $exception) {
$sfStages = [];
}
$missingStage = null;
// Upsert all stages for the team.
foreach ($sfStages as $sfStage) {
$selectable = true;
if (array_key_exists('IsActive', $sfStage)) {
$selectable = $sfStage['IsActive'];
}
$this->restoreAnyTrashedEntity($this->config->stages(), $sfStage['Id']);
$stage = $this->config->stages()->updateOrCreate([
'crm_provider_id' => $sfStage['Id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($sfStage['ApiName'], 0, 50),
'label' => mb_strimwidth($sfStage['MasterLabel'], 0, 191),
'type' => $type,
'sequence' => $sfStage['SortOrder'] ?? 0,
'is_selectable' => $selectable,
'probability' => $sfStage['DefaultProbability'] ?? null,
]);
if ($missingStageName && $missingStageName === $sfStage['ApiName']) {
$missingStage = $stage;
}
}
if ($missingStageName && $missingStage === null) {
// If they requested a stage that still doesn't exist, it must be inactive so lazy create it.
$missingStage = $this->config->stages()->create([
'crm_provider_id' => Uuid::uuid4(),
'team_id' => $this->team->id,
'name' => mb_strimwidth($missingStageName, 0, 50),
'label' => mb_strimwidth($missingStageName, 0, 191),
'type' => $type,
'sequence' => 0,
'is_selectable' => 0,
]);
}
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int
{
$syncCount = 0;
$fields = $this->getAllFieldsAsArray('lead');
if (\in_array('Id', $fields, true) === false) {
return $syncCount;
}
$query = '
SELECT ' . rtrim(implode(',', $fields), ',') . '
FROM Lead
WHERE LastModifiedDate > :since
ORDER BY LastModifiedDate ASC';
try {
$sfLeads = $this->queryHandler->query($query, [
'since' => $since->format('Y-m-d\TH:i:s\Z'),
]);
foreach ($sfLeads as $sfLead) {
// Only sync if previously imported.
if ($this->hasLead($sfLead['Id'])) {
$this->importLead($sfLead);
$syncCount++;
}
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
$this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::LEAD);
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncLead(string $crmId): ?Lead
{
$fields = $this->getAllFieldsAsArray('lead');
$sfLead = $this->getRecord('Lead', $crmId, $fields);
return $this->importLead($sfLead);
}
private function importLead($crmData): ?Lead
{
/** @var ?Stage $stage */
$stage = null;
if (isset($crmData['Status'])) {
// Get the current stage.
$stage = $this->config
->stages()
->where('name', $crmData['Status'])
->where('type', Stage::TYPE_LEAD)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_LEAD], $crmData['Status']);
}
}
// If we have no way of importing this, just return null :(
if ($stage === null) {
return null;
}
$countryCode = $crmData['CountryCode'] ?? null;
// Salesforce allows custom "countries" to be created. Disregard these.
if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {
$countryCode = null;
}
// If we have no country code, try to parse it from the country name.
if ($countryCode === null && empty($crmData['Country']) !== false) {
$countryCode = $this->convertCountryNameToCode($crmData['Country']);
}
// Trim to our width and attempt to parse it.
$number = mb_strimwidth($crmData['Phone'] ?? '', 0, 25);
$parsedNumber = parsePhoneNumber($countryCode, $number);
$mobilePhone = null;
if (empty($crmData['MobilePhone']) === false) {
// Trim to our width and attempt to parse it.
$number = mb_strimwidth($crmData['MobilePhone'], 0, 25);
$mobilePhone = phone_e164($countryCode, $number);
}
$convertedDate = null;
$convertedAccount = null;
$convertedOpportunity = null;
$convertedContact = null;
if ($crmData['IsConverted'] == 'true') {
$convertedDate = $crmData['ConvertedDate'];
if (empty($crmData['ConvertedAccountId']) === false) {
$convertedAccount = $this->config
->accounts()
->where('crm_provider_id', $crmData['ConvertedAccountId'])
->first();
if ($convertedAccount === null) {
try {
$convertedAccount = $this->syncAccount($crmData['ConvertedAccountId']);
} catch (HttpNotFoundException $exception) {
// Probably the user has no permissions to access the converted data.
}
}
}
if (empty($crmData['ConvertedOpportunityId']) === false) {
$convertedOpportunity = $this->config
->opportunities()
->where('crm_provider_id', $crmData['ConvertedOpportunityId'])
->first();
if ($convertedOpportunity === null) {
try {
$convertedOpportunity = $this->syncOpportunity($crmData['ConvertedOpportunityId']);
} catch (HttpNotFoundException $exception) {
// Probably the user has no permissions to access the converted data.
}
}
}
if (empty($crmData['ConvertedContactId']) === false) {
$convertedContact = $this->team
->crm
->contacts()
->where('crm_provider_id', $crmData['ConvertedContactId'])
->first();
if ($convertedContact === null) {
try {
$convertedContact = $this->syncContact($crmData['ConvertedContactId']);
} catch (HttpNotFoundException $exception) {
// Probably the user has no permissions to access the converted data.
}
}
}
}
if (empty($crmData['Company'])) {
$company = 'Unknown';
} else {
$company = mb_strimwidth($crmData['Company'], 0, 191);
}
$domain = null;
if (empty($crmData['Website']) === false) {
$domain = mb_strimwidth($crmData['Website'], 0, 191);
$domain = StringUtil::resolveDomain($domain);
}
$createdDate = null;
if (empty($crmData['CreatedDate']) === false) {
$createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');
}
$profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);
$data = [
'team_id' => $this->team->id,
'user_id' => $profile?->user_id,
'owner_id' => $crmData['OwnerId'] ?? '',
'company' => $company,
'domain' => $domain,
'name' => $crmData['Name'] ? mb_strimwidth($crmData['Name'], 0, 191) : '',
'title' => $crmData['Title'] ? mb_strimwidth($crmData['Title'], 0, 128) : null,
'email' => $crmData['Email'] ? mb_strimwidth($crmData['Email'], 0, 80) : null,
'phone' => $parsedNumber['phone'],
'ext' => $parsedNumber['ext'] ?? null,
'mobile_phone' => $mobilePhone,
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: $crmData['Id'],
modelType: Lead::class,
fileName: $crmData['Id'],
avatarText: $crmData['Name']
),
'stage_id' => $stage->id,
'record_type_id' => null,
'converted_at' => $convertedDate,
'converted_account_id' => $convertedAccount->id ?? null,
'converted_opportunity_id' => $convertedOpportunity->id ?? null,
'converted_contact_id' => $convertedContact->id ?? null,
'country_code' => $countryCode,
'remotely_created_at' => $createdDate,
];
$this->restoreAnyTrashedEntity($this->config->leads(), $crmData['Id']);
/** @var Lead $lead */
$lead = $this->config->leads()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);
if ($lead->wasChanged('converted_at') && $lead->getConvertedAt() !== null) {
$this->eventDispatcher->dispatch(new LeadConverted($lead));
}
$this->handleObjectDeletion($lead, $crmData);
return $lead;
}
/**
* @inheritdoc
*/
public function syncAccounts(Carbon $since, ?Carbon $to = null): int
{
$syncCount = 0;
$fields = $this->getAllFieldsAsArray('account');
if (\in_array('Id', $fields, true) === false) {
return $syncCount;
}
$query = '
SELECT ' . rtrim(implode(',', $fields), ',') . '
FROM Account
WHERE LastModifiedDate > :since
ORDER BY LastModifiedDate ASC';
try {
$sfAccounts = $this->queryHandler->query($query, [
'since' => $since->format('Y-m-d\TH:i:s\Z'),
]);
foreach ($sfAccounts as $sfAccount) {
// Only sync if previously imported.
if ($this->hasAccount($sfAccount['Id'])) {
$this->importAccount($sfAccount);
$syncCount++;
}
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
$this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::ACCOUNT);
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncAccount(string $crmId): ?Account
{
$fields = $this->getAllFieldsAsArray('account');
if (! in_array('Id', $fields, true)) {
$this->logger->info('[Salesforce] Sync account cancelled. Fields are not available.', [
'crmId' => $crmId,
'userId' => $this->profile->getUserId(),
]);
return null;
}
$sfAccount = $this->getRecord('Account', $crmId, $fields);
return $this->importAccount($sfAccount);
}
private function importAccount($crmData): Account
{
$countryCode = $crmData['BillingCountryCode'] ?? $crmData['ShippingCountryCode'] ?? null;
// Salesforce allows custom "countries" to be created. Disregard these.
if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {
$countryCode = null;
}
// If we have no country code, try to parse it from the country names.
if ($countryCode === null && empty($crmData['BillingCountry']) === false) {
$countryCode = $this->convertCountryNameToCode($crmData['BillingCountry']);
}
if ($countryCode === null && empty($crmData['ShippingCountry']) === false) {
$countryCode = $this->convertCountryNameToCode($crmData['ShippingCountry']);
}
if (empty($crmData['Phone']) === false) {
// Trim to our width and attempt to parse it.
$number = mb_strimwidth($crmData['Phone'], 0, 25);
$parsedNumber = parsePhoneNumber($countryCode, $number);
} else {
$parsedNumber = [];
}
$industry = null;
if (empty($crmData['Industry']) === false) {
$industry = mb_strimwidth($crmData['Industry'], 0, 40);
}
$domain = null;
if (empty($crmData['Website']) === false) {
$domain = mb_strimwidth($crmData['Website'], 0, 191);
$domain = StringUtil::resolveDomain($domain);
}
$createdDate = null;
if (empty($crmData['CreatedDate']) === false) {
$createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');
}
$profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);
$data = [
'team_id' => $this->team->id,
'user_id' => $profile?->user_id,
'owner_id' => $crmData['OwnerId'],
'name' => mb_strimwidth($crmData['Name'], 0, 191),
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: $crmData['Id'],
modelType: Account::class,
fileName: $crmData['Id'],
avatarText: $crmData['Name']
),
'industry' => $industry,
'domain' => $domain,
'phone' => $parsedNumber['phone'] ?? null,
'ext' => $parsedNumber['ext'] ?? null,
'country_code' => $countryCode,
'remotely_created_at' => $createdDate,
];
$this->restoreAnyTrashedEntity($this->config->accounts(), $crmData['Id']);
/** @var Account $account */
$account = $this->config->accounts()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);
$this->handleObjectDeletion($account, $crmData);
return $account;
}
/**
* @inheritdoc
*/
public function syncOpportunities(array $parameters, ?string $strategy = null): int
{
$strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);
$syncCount = 0;
$logParams = $parameters;
$parameters['profile'] = $this->profile;
$logParams['user'] = $this->profile->getUserId();
if (count($strategies) > 1) {
$this->logger->warning('[' . $this->getDisplayName() . '] Multiple sync strategies used', [
'teamId' => $this->team->getUuid(),
'params' => $logParams,
'strategies_count' => count($strategies),
]);
}
foreach ($strategies as $syncStrategy) {
$name = $syncStrategy->getStrategyName();
try {
$sfOpportunities = $syncStrategy->fetchOpportunities($parameters);
$totalRecords = $sfOpportunities->count();
foreach ($sfOpportunities as $sfOpportunity) {
$this->importOpportunity($sfOpportunity);
$syncCount++;
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
$this->logger->warning('[' . $this->getDisplayName() . '] No opportunities found', [
'teamId' => $this->team->getUuid(),
'name' => $name,
'params' => $logParams,
'reason' => $noResultsException->getMessage(),
]);
} catch (CrmException $crmException) {
// Nothing to sync.
$this->logger->warning('[' . $this->getDisplayName() . '] Opportunity sync failed', [
'teamId' => $this->team->getUuid(),
'name' => $name,
'params' => $logParams,
'reason' => $crmException->getMessage(),
]);
}
}
$this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::OPPORTUNITY, ['params' => $logParams]);
// debug to see how if count of opportunities reaches 1000
if ($syncCount >= 1000) {
$this->logger->info(
'[' . $this->getDisplayName() . '] Sync Opportunities - count warning',
[
'team_id' => $this->team->getId(),
'params' => $logParams,
'count' => $syncCount,
'strategies_count' => count($strategies),
'total_records' => $totalRecords ?? null,
]
);
}
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncOpportunity(string $crmId): ?Opportunity
{
$strategy = $this->opportunitySyncStrategyResolver->resolve(
$this->config,
OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY
);
$parameters = [
'profile' => $this->profile,
'crm_id' => $crmId,
];
try {
$sfOpportunity = $strategy->fetchOpportunities($parameters);
} catch (HttpNotFoundException $e) {
$this->logger->info('[' . $this->getDisplayName() . '] Opportunity not found', [
'teamId' => $this->team->id_string,
'crmId' => $crmId,
]);
return null;
} catch (CrmException $crmException) {
$this->logger->info('[' . $this->getDisplayName() . '] Opportunity sync failed', [
'teamId' => $this->team->id_string,
'crmId' => $crmId,
'exception' => $crmException->getMessage(),
]);
return null;
}
if ($sfOpportunity instanceof ArrayIterator) {
return $this->importOpportunity($sfOpportunity->getItems());
}
return $this->importOpportunity($sfOpportunity);
}
/**
* @throws HttpNotFoundException
*/
private function importOpportunity($crmData): ?Opportunity
{
$profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);
$account = null;
if (empty($crmData['AccountId']) === false) {
/** @var ?Account $account */
$account = $this->config->accounts()
->where('crm_provider_id', (string) $crmData['AccountId'])
->first();
if ($account === null) {
$account = $this->syncAccount($crmData['AccountId']);
}
}
$userId = $profile?->getUserId() ?? $account?->getUserId();
if ($us...
|
70643
|
NULL
|
NULL
|
NULL
|
|
70644
|
2536
|
13
|
2026-05-22T10:26:43.462678+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779445603462_m1.jpg...
|
PhpStorm
|
Commit Changes
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Diff
Changelist:
Changelist:
Changes
Show Diff
Rol Diff
Changelist:
Changelist:
Changes
Show Diff
Rollback...
Refresh
Group By
Expand All
Collapse All
1 modified
JY-20
Commit Message
Commit Message History
Git
Author:
Author:
Amend commit
Sign-off commit
Create extra commit with file movements
Commit Checks
Update copyright
Reformat code
Rearrange code
Optimize imports
Cleanup
Choose profile
Check malicious dependencies
Run external formatter
Configure
Perform SonarQube for IDE analysis
Find unused view files
Run Git hooks
Advanced Commit Checks
Analyze code
Choose profile
Check TODO
Configure
After Commit
Upload files to:
Upload files to:
<None>
Always use selected server or group of servers
Previous Difference
Next Difference
Jump to Source
Compare Previous File
Compare Next File
Go to Changed File…
Side-by-side viewer
Do not ignore
Highlight words
Collapse Unchanged Fragments
Synchronize Scrolling
Disable Editing
Settings
Help
4 differences
1aee7aad
<?php
namespace Jiminny\Services\Crm\Salesforce;
use Carbon\Carbon;
use Exception;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Events\Dispatcher;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Jiminny\Component\Country\CountriesMap;
use Jiminny\Contracts\Acl\PermissionEnum;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Contracts\Services\Crm\FetchRelatedActivityInterface;
use Jiminny\Contracts\Services\Crm\ImportsBusinessProcessesInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\SalesforceBatchSyncInterface;
use Jiminny\Contracts\Services\Crm\Provider\SalesforceInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\RemoteNoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SearchTaskInterface;
use Jiminny\Contracts\Services\Crm\SendSummaryToCrmInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SupportsObjectTypeParseInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmProfileRecordTypesInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Enums\CrmObject;
use Jiminny\Events\Activities\Crm\LeadConverted;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpBadRequestException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Exceptions\NoResultsException;
use Jiminny\Exceptions\ServiceUnavailableException;
use Jiminny\Jobs\Crm\NoteObject;
use Jiminny\Jobs\Crm\MatchActivitiesToNewOpportunity;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Calendar\CalendarEvent;
use Jiminny\Models\Contact;
use Jiminny\Models\Contracts\ActivityContract;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Models\Crm\ContactRole;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Crm\RecordType;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Playbook;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\TeamSettings;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\ContactRoleRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\Crm\RecordTypeFieldValuesRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Helpers\ArrayIterator;
use Jiminny\Services\Crm\MatchDomainByEmailInterface;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Services\Crm\ResolveCompanyNameByEmailTrait;
use Jiminny\Services\Crm\Salesforce\Fields\FieldHelper;
use Jiminny\Services\Crm\Salesforce\Fields\FieldTypeConverter;
use Jiminny\Services\Crm\Salesforce\Fields\ValueNormalizer;
use Jiminny\Services\Crm\Salesforce\ServiceTraits\FollowupActivityTrait;
use Jiminny\Services\Crm\Salesforce\ServiceTraits\LogActivityTrait;
use Jiminny\Services\Crm\Salesforce\ServiceTraits\RecordManipulationsTrait;
use Jiminny\Services\Crm\Salesforce\ServiceTraits\SyncFieldsTrait;
use Jiminny\Utils\CurrencyFormatter;
use Jiminny\Utils\StringUtil;
use Ramsey\Uuid\Uuid;
use Sentry\Laravel\Facade as Sentry;
class Service extends BaseService implements
SalesforceInterface,
SalesforceBatchSyncInterface,
SyncCrmEntitiesInterface,
SyncCrmProfileRecordTypesInterface,
ImportsBusinessProcessesInterface,
RemoteEntityManipulationInterface,
FetchRelatedActivityInterface,
SendSummaryToCrmInterface,
MatchDomainByEmailInterface,
SearchTaskInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
SupportsObjectTypeParseInterface,
RemoteNoteEntityManipulationInterface,
VerifyTaskExistsInterface
{
use ResolveCompanyNameByEmailTrait;
use SyncFieldsTrait;
use DeleteObjectsTrait;
use RecordManipulationsTrait;
use ServiceTraits\BatchSyncTrait;
use FollowupActivityTrait;
use LogActivityTrait;
/**
* Note Body Limit for the Old Note-Taking Tool
*
* @var int
*/
private const int CLASSIC_NOTE_MAX_LENGTH = 32000;
/**
* Note Content Limit for the New Notes
*
* @var int
*/
private const int ENHANCED_NOTE_MAX_LENGTH = 50000000;
private const string INSTALLED_PACKAGE_ID = '033Tw0000007bKbIAI';
private const int CACHE_TTL = 600;
private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day - 86400
/**
* @var Client
*/
protected $client;
protected PayloadBuilder $payloadBuilder;
protected QueryHandler $queryHandler;
private OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
public function __construct(
Client $client,
PayloadBuilder $payloadBuilder,
protected Dispatcher $eventDispatcher,
private readonly CountriesMap $countriesMap,
private readonly ProspectPhotoPathService $prospectPhotoPathService,
) {
parent::__construct();
$this->client = $client;
$this->payloadBuilder = $payloadBuilder;
$this->queryHandler = app(QueryHandler::class, [
'client' => $this->client,
'logger' => $this->logger,
]);
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
}
public function getDisplayName(): string
{
return 'Salesforce';
}
public function getJobDelay(): int
{
return 1;
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
return $user->getSocialAccount(SocialAccount::PROVIDER_SALESFORCE);
}
public function verifyTaskExists(Activity $activity): bool
{
$crmProviderId = $activity->getCrmProviderId();
$cacheKey = "crm_task_exists:{$this->config->getId()}:$crmProviderId";
return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($activity, $crmProviderId) {
$playbook = $this->getPlaybookFromActivity($activity);
if ($playbook === null) {
$this->logger->warning('[Salesforce] Cannot verify task - no playbook found', [
'activity' => $activity->getId(),
'crm_provider_id' => $crmProviderId,
]);
return false;
}
$objectType = $playbook->getActivityType() === Playbook::ACTIVITY_TYPE_EVENT ? 'Event' : 'Task';
try {
$record = $this->getRecord($objectType, $crmProviderId, ['Id', 'IsDeleted']);
return ! empty($record) && ($record['IsDeleted'] ?? false) === false;
} catch (HttpNotFoundException|HttpBadRequestException) {
$this->logger->info('[Salesforce] Activity record not found during verification', [
'activity' => $activity->getId(),
'object_type' => $objectType,
'crm_provider_id' => $crmProviderId,
'config_id' => $this->config->getId(),
]);
return false;
}
});
}
public function query(string $queryToRun, array $parameters = []): QueryIterator
{
// Due to poorly designed external calls, this method cannot be entirely removed
return $this->queryHandler->query($queryToRun, $parameters);
}
/*=========== Organization Information ===============*/
/**
* Get a list of all the API Versions for the instance.
*
* @throws CrmException
*
* @return mixed
*
*/
public function getApiVersions()
{
$url = $this->config->crm_base_url . '/services/data';
$response = $this->client->get($url);
return json_decode($response->getBody(), true);
}
/**
* Gets the valid recordTypes for a given Salesforce Object via the describe API.
*/
private function getRecordTypes(string $crmObject): array
{
$url = $this->client->getObjectsUrl() . $crmObject . '/describe';
$response = $this->client->get($url);
$jsonResponse = json_decode($response->getBody(), true);
$fields = [];
foreach ($jsonResponse['recordTypeInfos'] as $row) {
$fields[] = ['recordTypeId' => $row['recordTypeId'], 'default' => $row['defaultRecordTypeMapping']];
}
return $fields;
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return ValueNormalizer::normalize($fieldType, $fieldValue, $internal);
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
$defaultFields = ($activityType === Playbook::ACTIVITY_TYPE_TASK)
? FieldDefinitions::defaultTaskFields()
: FieldDefinitions::defaultEventFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
// Setup the activity field as the default Type.
/** @var Field $activityField */
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'Type',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK, Playbook::ACTIVITY_TYPE_EVENT];
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldFilter = ($activityType === Playbook::ACTIVITY_TYPE_TASK)
? FieldDefinitions::taskFollowupFieldsFilter()
: FieldDefinitions::eventFollowupFieldsFilter();
foreach ($fieldFilter as $eachFilter) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $eachFilter);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
/**
* This one is now called only when ImportActivityTypes is triggered or SyncFieldMetadata executed manually
* Regular sync now uses SharedSyncFieldsTrait -> syncSingleObjectType
* Needs to be replaced later on
*/
public function syncField(Field $field): void
{
try {
if (FieldHelper::isCustomField($field)) {
$query = '
SELECT
Id, Metadata, TableEnumOrId
FROM
CustomField
WHERE
DeveloperName = :fieldName
AND
TableEnumOrId = :fieldType
AND
NamespacePrefix = :namespacePrefix';
// We need to constrain the field lookup to the object, in case it's used in multiple places.
$objectType = \in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true)
? 'activity'
: $field->object_type;
$sfFields = $this->queryHandler->metadata($query, [
'fieldName' => substr($field->crm_provider_id, 0, -\strlen('__c')),
'fieldType' => ucfirst($objectType),
// This is used to ensure we only consider the field within the org, not installed packages.
'namespacePrefix' => 'null',
]);
// There is always 1 result at this point.
$sfField = $sfFields->current();
// Sync field metadata.
$metadata = $sfField['Metadata'];
$field->description = mb_strimwidth($metadata['description'] ?? '', 0, 191);
$field->label = mb_strimwidth($metadata['label'] ?? '', 0, Field::LABEL_MAX_LENGTH);
$field->type = $this->convertFieldType($metadata['type'], $field->getEntityName());
$field->is_mandatory = ($metadata['required'] === true);
$field->length = $metadata['length'];
$field->default_value = mb_strimwidth(trim($metadata['defaultValue'] ?? '', '"'), 0, 191);
$field->save();
} else {
$query = '
SELECT
Id, DataType, DeveloperName, Label, Length, Description
FROM
FieldDefinition
WHERE
DurableId = :entityName';
$entityName = $field->getEntityName();
$sfFields = $this->queryHandler->metadata($query, [
'entityName' => $entityName,
]);
// There is always 1 result at this point.
$sfField = $sfFields->current();
$convertedType = $this->convertFieldType($sfField['DataType'], $entityName);
$label = mb_strimwidth($sfField['Label'], 0, Field::LABEL_MAX_LENGTH);
if ($field->isBusinessType()) {
$label = 'Opportunity Type';
}
$field->description = mb_strimwidth($sfField['Description'], 0, Field::DESCRIPTION_MAX_LENGTH);
$field->label = $label;
$field->type = $convertedType;
$field->length = $sfField['Length'];
$field->save();
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
}
private function convertFieldType(string $from, ?string $entityName = null): string
{
$converter = new FieldTypeConverter();
return $converter->convert($from, $entityName);
}
/**
* @inheritdoc
*/
public function importPicklistValues(Field $field): array
{
$values = [];
$fieldValues = [];
try {
if (FieldHelper::isCustomField($field)) {
$query = '
SELECT
Id, Metadata, TableEnumOrId
FROM
CustomField
WHERE
DeveloperName = :fieldName
AND
TableEnumOrId = :fieldType
AND
NamespacePrefix = :namespacePrefix';
// We need to constrain the field lookup to the object, in case it's used in multiple places.
$objectType = \in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true) ?
'activity' : $field->object_type;
$sfFields = $this->queryHandler->metadata($query, [
'fieldName' => substr($field->crm_provider_id, 0, -\strlen('__c')),
'fieldType' => ucfirst($objectType),
// This is used to ensure we only consider the field within the org, not installed packages.
'namespacePrefix' => 'null',
]);
// There is always 1 result at this point.
$sfField = $sfFields->current();
$valueSet = $sfField['Metadata']['valueSet'];
if ($valueSet['valueSetName'] === null) {
// Local picklist values can be obtained easily.
$picklistValues = $valueSet['valueSetDefinition']['value'];
} else {
// But for some fields, we just get the Global Value Picklist pointer so need to do more work.
$picklistValues = $this->importGlobalValuePicklistValues($valueSet['valueSetName']);
}
// Import all active values.
foreach ($picklistValues as $i => $sfFieldValue) {
// Setup default value.
if ($sfFieldValue['default']) {
$field->update(['default_value' => $sfFieldValue['valueName']]);
}
// This comes through as null if active (lol).
if ($sfFieldValue['isActive'] !== false) {
$values[] = [
'value' => $sfFieldValue['valueName'],
'label' => $sfFieldValue['valueName'],
'sequence' => $i,
'is_default' => $sfFieldValue['default'],
];
}
}
} else {
$objectFields = $this->getObjectFields($field->object_type);
$fieldId = $field->crm_provider_id;
// Only work with our field of interest.
$objectField = array_filter($objectFields, function ($item) use ($fieldId) {
return $item['name'] === $fieldId;
});
$objectField = array_shift($objectField);
if (empty($objectField['picklistValues']) === false) {
foreach ($objectField['picklistValues'] as $i => $sfFieldValue) {
// Skip inactive values.
if ($sfFieldValue['active'] === false) {
continue;
}
// Setup default value.
if ($sfFieldValue['defaultValue']) {
$field->update(['default_value' => $sfFieldValue['value']]);
}
$values[] = [
'value' => $sfFieldValue['value'],
'label' => $sfFieldValue['label'],
'sequence' => $i,
'is_default' => $sfFieldValue['defaultValue'],
];
}
}
}
$fieldsToPurge = $field->values()->get()->pluck('value')->toArray();
foreach ($values as $value) {
$value['value'] = substr($value['value'] ?? '', 0, 255);
$fieldValues[] = $field->values()->updateOrCreate([
'value' => $value['value'],
], $value);
// Remove this value from the ones we are going to purge.
if (($key = array_search($value['value'], $fieldsToPurge, true)) !== false) {
unset($fieldsToPurge[$key]);
}
}
// Delete the old values that are no longer used.
// Get IDs of the values to be deleted
$valuesToDelete = $field->values()->whereIn('value', $fieldsToPurge);
$valuesToDeleteIds = $valuesToDelete->pluck('id');
if (! $valuesToDeleteIds->isEmpty()) {
$recordTypeFieldValuesRepository = app(RecordTypeFieldValuesRepository::class);
$recordTypeFieldValuesRepository->deleteForCrmFieldValueIds($valuesToDeleteIds->toArray());
// Now safely delete from crm_field_values
$valuesToDelete->delete();
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
return $fieldValues;
}
/**
* Gets values from Global Value Picklists.
*/
private function importGlobalValuePicklistValues(string $picklistName): array
{
$query = '
SELECT
Metadata
FROM
GlobalValueSet
WHERE
DeveloperName = :picklistName
LIMIT 1';
try {
$sfValues = $this->queryHandler->metadata($query, [
'picklistName' => $picklistName,
]);
// There is always 1 result at this point.
$sfValue = $sfValues->current();
return $sfValue['Metadata']['customValue'];
} catch (NoResultsException $noResultsException) {
// Nothing returned.
return [];
}
}
/**
* @inheritdoc
*/
public function syncProfileRecordTypes(): void
{
$objectTypes = [
'lead',
'account',
'contact',
'opportunity',
'task',
'event',
];
foreach ($objectTypes as $objectType) {
try {
$crmRecordTypes = $this->getRecordTypes(ucfirst($objectType));
foreach ($crmRecordTypes as $crmRecordType) {
// If the record type is default and not the Master type, set this.
if ($crmRecordType['default'] && $crmRecordType['recordTypeId'] !== '012000000000000AAA') {
$recordType = $this->config->recordTypes()
->where('crm_provider_id', $crmRecordType['recordTypeId'])
->first();
if ($recordType) {
$this->profile->{$objectType . '_record_type_id'} = $recordType->id;
}
}
}
} catch (HttpNotFoundException $exception) {
Log::error('No access to ' . $objectType . ' object, skipping...');
// XXX: should we log this fact somewhere?
continue;
}
}
if ($this->profile->isDirty()) {
$this->profile->save();
}
}
/**
* Gets business processes.
*/
public function importBusinessProcesses(): void
{
$query = '
SELECT
Id, IsActive, Name, TableEnumOrId
FROM
BusinessProcess
WHERE
TableEnumOrId IN (\'Lead\',\'Opportunity\')';
try {
$sfProcesses = $this->queryHandler->query($query);
// Upsert all processes for the team.
foreach ($sfProcesses as $sfProcess) {
/** @var BusinessProcess $businessProcess */
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => $sfProcess['Id'],
], [
'team_id' => $this->team->id,
'name' => $sfProcess['Name'],
'type' => $sfProcess['TableEnumOrId'] === 'Lead' ? 'lead' : 'opportunity',
'is_selectable' => $sfProcess['IsActive'],
]);
$this->importBusinessProcessStages($businessProcess);
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
}
/**
* Gets business process stages.
*/
private function importBusinessProcessStages(BusinessProcess $businessProcess): void
{
$query = '
SELECT
Metadata
FROM
BusinessProcess
WHERE
Id = :processId';
try {
$stages = [];
$sfProcessStages = $this->queryHandler->metadata($query, [
'processId' => $businessProcess->crm_provider_id,
]);
// There is always 1 result at this point.
$sfProcessStage = $sfProcessStages->current();
// Upsert all processes for the team.
foreach ($sfProcessStage['Metadata']['values'] as $sfProcessStage) {
$sanitizedName = urldecode($sfProcessStage['valueName']); // Must decode: "%2C" becomes "," etc.
$stage = $businessProcess->crm->stages()
// This MUST match on label because this API doesn't use API Name.
->where('label', $sanitizedName)
->where('type', $businessProcess->type)
->where('is_selectable', 1)
->first();
if ($stage) {
$stages[] = $stage->id;
}
}
$businessProcess->stages()->sync($stages);
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
}
/**
* Gets record types.
*/
public function importRecordTypes(): void
{
$query = '
SELECT
Id, IsActive, Name, BusinessProcessId, SobjectType
FROM
RecordType';
try {
$sfRecordTypes = $this->queryHandler->query($query);
// Upsert all record types for the process.
foreach ($sfRecordTypes as $sfRecordType) {
$businessProcess = null;
if ($sfRecordType['BusinessProcessId']) {
$businessProcess = $this->config->businessProcesses()
->where('crm_provider_id', $sfRecordType['BusinessProcessId'])
->first();
}
/** @var RecordType $recordType */
$recordType = $this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => $sfRecordType['Id'],
], [
'team_id' => $this->team->id,
'type' => mb_strtolower($sfRecordType['SobjectType']),
'name' => $sfRecordType['Name'],
'is_selectable' => $sfRecordType['IsActive'],
'business_process_id' => $businessProcess->id ?? null,
]);
$this->importRecordTypeFieldValues($recordType);
}
} catch (NoResultsException $noResultsException) {
// Do nothing.
}
}
/**
* Import record type - field value mappings. This only works for standard fields.
*/
private function importRecordTypeFieldValues(RecordType $recordType): void
{
try {
$query = '
SELECT
Metadata
FROM
RecordType
WHERE
Id = :recordTypeId';
$sfFields = $this->queryHandler->metadata($query, [
'recordTypeId' => $recordType->crm_provider_id,
]);
// There is always 1 result at this point.
$sfField = $sfFields->current();
// Sync field metadata.
$picklists = $sfField['Metadata']['picklistValues'];
foreach ($picklists as $picklist) {
$field = $this->config->fields()->where([
'type' => Field::TYPE_PICKLIST,
'object_type' => $recordType->type,
'crm_provider_id' => $picklist['picklist'],
])->first();
if ($field) {
$fieldValues = [];
foreach ($picklist['values'] as $value) {
// Must decode: "%2C" becomes "," etc.
$fieldValue = $field->values()
->where('value', urldecode($value['valueName']))
->first();
if ($fieldValue) {
$fieldValues[] = $fieldValue->id;
}
}
$recordType->fieldValues()->sync($fieldValues);
}
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
}
/**
* @inheritdoc
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$params = [];
$missingStage = null;
if ($types === null) {
$types = [Stage::TYPE_LEAD, Stage::TYPE_OPPORTUNITY];
}
foreach ($types as $type) {
if ($type === Stage::TYPE_LEAD) {
$query = '
SELECT
Id, ApiName, MasterLabel, SortOrder
FROM
LeadStatus';
} else {
$query = '
SELECT
Id, ApiName, MasterLabel, IsActive, SortOrder, DefaultProbability
FROM
OpportunityStage';
}
if ($missingStageName) {
$escapedStageName = ValueNormalizer::replaceQueryWithStringLiterals($missingStageName);
$query .= ' WHERE ApiName = :stageName';
$params = [
'stageName' => $escapedStageName,
];
}
try {
$sfStages = $this->queryHandler->query($query, $params);
} catch (NoResultsException $exception) {
$sfStages = [];
}
$missingStage = null;
// Upsert all stages for the team.
foreach ($sfStages as $sfStage) {
$selectable = true;
if (array_key_exists('IsActive', $sfStage)) {
$selectable = $sfStage['IsActive'];
}
$this->restoreAnyTrashedEntity($this->config->stages(), $sfStage['Id']);
$stage = $this->config->stages()->updateOrCreate([
'crm_provider_id' => $sfStage['Id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($sfStage['ApiName'], 0, 50),
'label' => mb_strimwidth($sfStage['MasterLabel'], 0, 191),
'type' => $type,
'sequence' => $sfStage['SortOrder'] ?? 0,
'is_selectable' => $selectable,
'probability' => $sfStage['DefaultProbability'] ?? null,
]);
if ($missingStageName && $missingStageName === $sfStage['ApiName']) {
$missingStage = $stage;
}
}
if ($missingStageName && $missingStage === null) {
// If they requested a stage that still doesn't exist, it must be inactive so lazy create it.
$missingStage = $this->config->stages()->create([
'crm_provider_id' => Uuid::uuid4(),
'team_id' => $this->team->id,
'name' => mb_strimwidth($missingStageName, 0, 50),
'label' => mb_strimwidth($missingStageName, 0, 191),
'type' => $type,
'sequence' => 0,
'is_selectable' => 0,
]);
}
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int
{
$syncCount = 0;
$fields = $this->getAllFieldsAsArray('lead');
if (\in_array('Id', $fields, true) === false) {
return $syncCount;
}
$query = '
SELECT ' . rtrim(implode(',', $fields), ',') . '
FROM Lead
WHERE LastModifiedDate > :since
ORDER BY LastModifiedDate ASC';
try {
$sfLeads = $this->queryHandler->query($query, [
'since' => $since->format('Y-m-d\TH:i:s\Z'),
]);
foreach ($sfLeads as $sfLead) {
// Only sync if previously imported.
if ($this->hasLead($sfLead['Id'])) {
$this->importLead($sfLead);
$syncCount++;
}
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
$this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::LEAD);
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncLead(string $crmId): ?Lead
{
$fields = $this->getAllFieldsAsArray('lead');
$sfLead = $this->getRecord('Lead', $crmId, $fields);
return $this->importLead($sfLead);
}
private function importLead($crmData): ?Lead
{
/** @var ?Stage $stage */
$stage = null;
if (isset($crmData['Status'])) {
// Get the current stage.
$stage = $this->config
->stages()
->where('name', $crmData['Status'])
->where('type', Stage::TYPE_LEAD)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_LEAD], $crmData['Status']);
}
}
// If we have no way of importing this, just return null :(
if ($stage === null) {
return null;
}
$countryCode = $crmData['CountryCode'] ?? null;
// Salesforce allows custom "countries" to be created. Disregard these.
if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {
$countryCode = null;
}
// If we have no country code, try to parse it from the country name.
if ($countryCode === null && empty($crmData['Country']) !== false) {
$countryCode = $this->convertCountryNameToCode($crmData['Country']);
}
// Trim to our width and attempt to parse it.
$number = mb_strimwidth($crmData['Phone'] ?? '', 0, 25);
$parsedNumber = parsePhoneNumber($countryCode, $number);
$mobilePhone = null;
if (empty($crmData['MobilePhone']) === false) {
// Trim to our width and attempt to parse it.
$number = mb_strimwidth($crmData['MobilePhone'], 0, 25);
$mobilePhone = phone_e164($countryCode, $number);
}
$convertedDate = null;
$convertedAccount = null;
$convertedOpportunity = null;
$convertedContact = null;
if ($crmData['IsConverted'] == 'true') {
$convertedDate = $crmData['ConvertedDate'];
if (empty($crmData['ConvertedAccountId']) === false) {
$convertedAccount = $this->config
->accounts()
->where('crm_provider_id', $crmData['ConvertedAccountId'])
->first();
if ($convertedAccount === null) {
try {
$convertedAccount = $this->syncAccount($crmData['ConvertedAccountId']);
} catch (HttpNotFoundException $exception) {
// Probably the user has no permissions to access the converted data.
}
}
}
if (empty($crmData['ConvertedOpportunityId']) === false) {
$convertedOpportunity = $this->config
->opportunities()
->where('crm_provider_id', $crmData['ConvertedOpportunityId'])
->first();
if ($convertedOpportunity === null) {
try {
$convertedOpportunity = $this->syncOpportunity($crmData['ConvertedOpportunityId']);
} catch (HttpNotFoundException $exception) {
// Probably the user has no permissions to access the converted data.
}
}
}
if (empty($crmData['ConvertedContactId']) === false) {
$convertedContact = $this->team
->crm
->contacts()
->where('crm_provider_id', $crmData['ConvertedContactId'])
->first();
if ($convertedContact === null) {
try {
$convertedContact = $this->syncContact($crmData['ConvertedContactId']);
} catch (HttpNotFoundException $exception) {
// Probably the user has no permissions to access the converted data.
}
}
}
}
if (empty($crmData['Company'])) {
$company = 'Unknown';
} else {
$company = mb_strimwidth($crmData['Company'], 0, 191);
}
$domain = null;
if (empty($crmData['Website']) === false) {
$domain = mb_strimwidth($crmData['Website'], 0, 191);
$domain = StringUtil::resolveDomain($domain);
}
$createdDate = null;
if (empty($crmData['CreatedDate']) === false) {
$createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');
}
$profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);
$data = [
'team_id' => $this->team->id,
'user_id' => $profile?->user_id,
'owner_id' => $crmData['OwnerId'] ?? '',
'company' => $company,
'domain' => $domain,
'name' => $crmData['Name'] ? mb_strimwidth($crmData['Name'], 0, 191) : '',
'title' => $crmData['Title'] ? mb_strimwidth($crmData['Title'], 0, 128) : null,
'email' => $crmData['Email'] ? mb_strimwidth($crmData['Email'], 0, 80) : null,
'phone' => $parsedNumber['phone'],
'ext' => $parsedNumber['ext'] ?? null,
'mobile_phone' => $mobilePhone,
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: $crmData['Id'],
modelType: Lead::class,
fileName: $crmData['Id'],
avatarText: $crmData['Name']
),
'stage_id' => $stage->id,
'record_type_id' => null,
'converted_at' => $convertedDate,
'converted_account_id' => $convertedAccount->id ?? null,
'converted_opportunity_id' => $convertedOpportunity->id ?? null,
'converted_contact_id' => $convertedContact->id ?? null,
'country_code' => $countryCode,
'remotely_created_at' => $createdDate,
];
$this->restoreAnyTrashedEntity($this->config->leads(), $crmData['Id']);
/** @var Lead $lead */
$lead = $this->config->leads()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);
if ($lead->wasChanged('converted_at') && $lead->getConvertedAt() !== null) {
$this->eventDispatcher->dispatch(new LeadConverted($lead));
}
$this->handleObjectDeletion($lead, $crmData);
return $lead;
}
/**
* @inheritdoc
*/
public function syncAccounts(Carbon $since, ?Carbon $to = null): int
{
$syncCount = 0;
$fields = $this->getAllFieldsAsArray('account');
if (\in_array('Id', $fields, true) === false) {
return $syncCount;
}
$query = '
SELECT ' . rtrim(implode(',', $fields), ',') . '
FROM Account
WHERE LastModifiedDate > :since
ORDER BY LastModifiedDate ASC';
try {
$sfAccounts = $this->queryHandler->query($query, [
'since' => $since->format('Y-m-d\TH:i:s\Z'),
]);
foreach ($sfAccounts as $sfAccount) {
// Only sync if previously imported.
if ($this->hasAccount($sfAccount['Id'])) {
$this->importAccount($sfAccount);
$syncCount++;
}
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
$this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::ACCOUNT);
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncAccount(string $crmId): ?Account
{
$fields = $this->getAllFieldsAsArray('account');
if (! in_array('Id', $fields, true)) {
$this->logger->info('[Salesforce] Sync account cancelled. Fields are not available.', [
'crmId' => $crmId,
'userId' => $this->profile->getUserId(),
]);
return null;
}
$sfAccount = $this->getRecord('Account', $crmId, $fields);
return $this->importAccount($sfAccount);
}
private function importAccount($crmData): Account
{
$countryCode = $crmData['BillingCountryCode'] ?? $crmData['ShippingCountryCode'] ?? null;
// Salesforce allows custom "countries" to be created. Disregard these.
if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {
$countryCode = null;
}
// If we have no country code, try to parse it from the country names.
if ($countryCode === null && empty($crmData['BillingCountry']) === false) {
$countryCode = $this->convertCountryNameToCode($crmData['BillingCountry']);
}
if ($countryCode === null && empty($crmData['ShippingCountry']) === false) {
$countryCode = $this->convertCountryNameToCode($crmData['ShippingCountry']);
}
if (empty($crmData['Phone']) === false) {
// Trim to our width and attempt to parse it.
$number = mb_strimwidth($crmData['Phone'], 0, 25);
$parsedNumber = parsePhoneNumber($countryCode, $number);
} else {
$parsedNumber = [];
}
$industry = null;
if (empty($crmData['Industry']) === false) {
$industry = mb_strimwidth($crmData['Industry'], 0, 40);
}
$domain = null;
if (empty($crmData['Website']) === false) {
$domain = mb_strimwidth($crmData['Website'], 0, 191);
$domain = StringUtil::resolveDomain($domain);
}
$createdDate = null;
if (empty($crmData['CreatedDate']) === false) {
$createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');
}
$profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);
$data = [
'team_id' => $this->team->id,
'user_id' => $profile?->user_id,
'owner_id' => $crmData['OwnerId'],
'name' => mb_strimwidth($crmData['Name'], 0, 191),
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: $crmData['Id'],
modelType: Account::class,
fileName: $crmData['Id'],
avatarText: $crmData['Name']
),
'industry' => $industry,
'domain' => $domain,
'phone' => $parsedNumber['phone'] ?? null,
'ext' => $parsedNumber['ext'] ?? null,
'country_code' => $countryCode,
'remotely_created_at' => $createdDate,
];
$this->restoreAnyTrashedEntity($this->config->accounts(), $crmData['Id']);
/** @var Account $account */
$account = $this->config->accounts()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);
$this->handleObjectDeletion($account, $crmData);
return $account;
}
/**
* @inheritdoc
*/
public function syncOpportunities(array $parameters, ?string $strategy = null): int
{
$strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);
$syncCount = 0;
$logParams = $parameters;
$parameters['profile'] = $this->profile;
$logParams['user'] = $this->profile->getUserId();
if (count($strategies) > 1) {
$this->logger->warning('[' . $this->getDisplayName() . '] Multiple sync strategies used', [
'teamId' => $this->team->getUuid(),
'params' => $logParams,
'strategies_count' => count($strategies),
]);
}
foreach ($strategies as $syncStrategy) {
$name = $syncStrategy->getStrategyName();
try {
$sfOpportunities = $syncStrategy->fetchOpportunities($parameters);
$totalRecords = $sfOpportunities->count();
foreach ($sfOpportunities as $sfOpportunity) {
$this->importOpportunity($sfOpportunity);
$syncCount++;
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
$this->logger->warning('[' . $this->getDisplayName() . '] No opportunities found', [
'teamId' => $this->team->getUuid(),
'name' => $name,
'params' => $logParams,
'reason' => $noResultsException->getMessage(),
]);
} catch (CrmException $crmException) {
// Nothing to sync.
$this->logger->warning('[' . $this->getDisplayName() . '] Opportunity sync failed', [
'teamId' => $this->team->getUuid(),
'name' => $name,
'params' => $logParams,
'reason' => $crmException->getMessage(),
]);
}
}
$this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::OPPORTUNITY, ['params' => $logParams]);
// debug to see how if count of opportunities reaches 1000
if ($syncCount >= 1000) {
$this->logger->info(
'[' . $this->getDisplayName() . '] Sync Opportunities - count warning',
[
'team_id' => $this->team->getId(),
'params' => $logParams,
'count' => $syncCount,
'strategies_count' => count($strategies),
'total_records' => $totalRecords ?? null,
]
);
}
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncOpportunity(string $crmId): ?Opportunity
{
$strategy = $this->opportunitySyncStrategyResolver->resolve(
$this->config,
OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY
);
$parameters = [
'profile' => $this->profile,
'crm_id' => $crmId,
];
try {
$sfOpportunity = $strategy->fetchOpportunities($parameters);
} catch (HttpNotFoundException $e) {
$this->logger->info('[' . $this->getDisplayName() . '] Opportunity not found', [
'teamId' => $this->team->id_string,
'crmId' => $crmId,
]);
return null;
} catch (CrmException $crmException) {
$this->logger->info('[' . $this->getDisplayName() . '] Opportunity sync failed', [
'teamId' => $this->team->id_string,
'crmId' => $crmId,
'exception' => $crmException->getMessage(),
]);
return null;
}
if ($sfOpportunity instanceof ArrayIterator) {
return $this->importOpportunity($sfOpportunity->getItems());
}
return $this->importOpportunity($sfOpportunity);
}
/**
* @throws HttpNotFoundException
*/
private function importOpportunity($crmData): ?Opportunity
{
$profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);
$account = null;
if (empty($crmData['AccountId']) === false) {
/** @var ?Account $account */
$account = $this->config->accounts()
->where('crm_provider_id', (string) $crmData['AccountId'])
->first();
if ($account === null) {
$account = $this->syncAccount($crmData['AccountId']);
}
}
$userId = $profile?->getUserId() ?? $account?->getUserId();
if ($us...
|
[{"role":"AXStaticText","text& [{"role":"AXStaticText","text":"Diff","depth":1,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Changelist:","depth":1,"on_screen":true,"role_description":"text"},{"role":"AXPopUpButton","text":"Changelist:","depth":1,"on_screen":true,"role_description":"pop up button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Changes","depth":6,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Show Diff","depth":2,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Rollback...","depth":2,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Refresh","depth":2,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Group By","depth":2,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand All","depth":2,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse All","depth":2,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"1 modified","depth":1,"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"JY-20","depth":2,"on_screen":true,"value":"JY-20","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Commit Message","depth":1,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Commit Message History","depth":2,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Git","depth":2,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Author:","depth":2,"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"Author:","depth":3,"on_screen":true,"role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Amend commit","depth":2,"on_screen":true,"help_text":"Merge this commit with the previous one","role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Sign-off commit","depth":2,"on_screen":true,"help_text":"<html>Adds the following line at the end of the commit message:<br/>Signed-off by: Lukas Kovalik <kovaliklukas@gmail.com></html>","role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Create extra commit with file movements","depth":2,"on_screen":false,"role_description":"checkbox","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Commit Checks","depth":2,"on_screen":true,"role_description":"text"},{"role":"AXCheckBox","text":"Update copyright","depth":2,"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Reformat code","depth":2,"on_screen":true,"help_text":"","role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Rearrange code","depth":2,"on_screen":true,"help_text":"","role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Optimize imports","depth":2,"on_screen":true,"help_text":"","role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Cleanup","depth":2,"on_screen":true,"help_text":"","role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"Choose profile","depth":2,"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Check malicious dependencies","depth":2,"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Run external formatter","depth":2,"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"Configure","depth":2,"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Perform SonarQube for IDE analysis","depth":2,"on_screen":true,"help_text":"","role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Find unused view files","depth":2,"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Run Git hooks","depth":2,"on_screen":false,"help_text":"If unchecked, Git hooks will be skipped with the '--no-verify' option for the upcoming commit","role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Advanced Commit Checks","depth":2,"on_screen":true,"role_description":"text"},{"role":"AXCheckBox","text":"Analyze code","depth":2,"on_screen":true,"help_text":"","role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"Choose profile","depth":2,"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Check TODO","depth":2,"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"Configure","depth":2,"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"After Commit","depth":2,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Upload files to:","depth":2,"on_screen":true,"role_description":"text"},{"role":"AXPopUpButton","text":"Upload files to:","depth":2,"on_screen":true,"role_description":"pop up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"<None>","depth":7,"on_screen":false,"role_description":"text"},{"role":"AXCheckBox","text":"Always use selected server or group of servers","depth":2,"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Previous Difference","depth":2,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Difference","depth":2,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Jump to Source","depth":2,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Compare Previous File","depth":2,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Compare Next File","depth":2,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Go to Changed File…","depth":2,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Side-by-side viewer","depth":2,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Do not ignore","depth":2,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Highlight words","depth":2,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse Unchanged Fragments","depth":2,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Synchronize Scrolling","depth":2,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Disable Editing","depth":2,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Settings","depth":2,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Help","depth":2,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"4 differences","depth":1,"on_screen":true,"role_description":"text"},{"role":"AXTextField","text":"1aee7aad","depth":1,"on_screen":true,"value":"1aee7aad","help_text":"text/plain","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Salesforce;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse Illuminate\\Database\\Eloquent\\Relations\\HasMany;\nuse Illuminate\\Events\\Dispatcher;\nuse Illuminate\\Support\\Facades\\Cache;\nuse Illuminate\\Support\\Facades\\Log;\nuse Illuminate\\Support\\Str;\nuse Jiminny\\Component\\Country\\CountriesMap;\nuse Jiminny\\Contracts\\Acl\\PermissionEnum;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\FetchRelatedActivityInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\ImportsBusinessProcessesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\SalesforceBatchSyncInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\SalesforceInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteNoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SearchTaskInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SendSummaryToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SupportsObjectTypeParseInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmProfileRecordTypesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Enums\\CrmObject;\nuse Jiminny\\Events\\Activities\\Crm\\LeadConverted;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpBadRequestException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Exceptions\\NoResultsException;\nuse Jiminny\\Exceptions\\ServiceUnavailableException;\nuse Jiminny\\Jobs\\Crm\\NoteObject;\nuse Jiminny\\Jobs\\Crm\\MatchActivitiesToNewOpportunity;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Calendar\\CalendarEvent;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Contracts\\ActivityContract;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Configuration;\nuse Jiminny\\Models\\Crm\\ContactRole;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Crm\\RecordType;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\TeamSettings;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\ContactRoleRepository;\nuse Jiminny\\Repositories\\Crm\\FieldRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Repositories\\Crm\\RecordTypeFieldValuesRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\Helpers\\ArrayIterator;\nuse Jiminny\\Services\\Crm\\MatchDomainByEmailInterface;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Services\\Crm\\ResolveCompanyNameByEmailTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\Fields\\FieldHelper;\nuse Jiminny\\Services\\Crm\\Salesforce\\Fields\\FieldTypeConverter;\nuse Jiminny\\Services\\Crm\\Salesforce\\Fields\\ValueNormalizer;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\FollowupActivityTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\LogActivityTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\RecordManipulationsTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\SyncFieldsTrait;\nuse Jiminny\\Utils\\CurrencyFormatter;\nuse Jiminny\\Utils\\StringUtil;\nuse Ramsey\\Uuid\\Uuid;\nuse Sentry\\Laravel\\Facade as Sentry;\n\nclass Service extends BaseService implements\n SalesforceInterface,\n SalesforceBatchSyncInterface,\n SyncCrmEntitiesInterface,\n SyncCrmProfileRecordTypesInterface,\n ImportsBusinessProcessesInterface,\n RemoteEntityManipulationInterface,\n FetchRelatedActivityInterface,\n SendSummaryToCrmInterface,\n MatchDomainByEmailInterface,\n SearchTaskInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n SupportsObjectTypeParseInterface,\n RemoteNoteEntityManipulationInterface,\n VerifyTaskExistsInterface\n{\n use ResolveCompanyNameByEmailTrait;\n use SyncFieldsTrait;\n use DeleteObjectsTrait;\n use RecordManipulationsTrait;\n use ServiceTraits\\BatchSyncTrait;\n use FollowupActivityTrait;\n use LogActivityTrait;\n\n /**\n * Note Body Limit for the Old Note-Taking Tool\n *\n * @var int\n */\n private const int CLASSIC_NOTE_MAX_LENGTH = 32000;\n\n /**\n * Note Content Limit for the New Notes\n *\n * @var int\n */\n private const int ENHANCED_NOTE_MAX_LENGTH = 50000000;\n\n private const string INSTALLED_PACKAGE_ID = '033Tw0000007bKbIAI';\n\n private const int CACHE_TTL = 600;\n\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day - 86400\n\n /**\n * @var Client\n */\n protected $client;\n\n protected PayloadBuilder $payloadBuilder;\n protected QueryHandler $queryHandler;\n\n private OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n\n public function __construct(\n Client $client,\n PayloadBuilder $payloadBuilder,\n protected Dispatcher $eventDispatcher,\n private readonly CountriesMap $countriesMap,\n private readonly ProspectPhotoPathService $prospectPhotoPathService,\n ) {\n parent::__construct();\n\n $this->client = $client;\n $this->payloadBuilder = $payloadBuilder;\n $this->queryHandler = app(QueryHandler::class, [\n 'client' => $this->client,\n 'logger' => $this->logger,\n ]);\n $this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n }\n\n public function getDisplayName(): string\n {\n return 'Salesforce';\n }\n\n public function getJobDelay(): int\n {\n return 1;\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n return $user->getSocialAccount(SocialAccount::PROVIDER_SALESFORCE);\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($activity, $crmProviderId) {\n $playbook = $this->getPlaybookFromActivity($activity);\n\n if ($playbook === null) {\n $this->logger->warning('[Salesforce] Cannot verify task - no playbook found', [\n 'activity' => $activity->getId(),\n 'crm_provider_id' => $crmProviderId,\n ]);\n\n return false;\n }\n\n $objectType = $playbook->getActivityType() === Playbook::ACTIVITY_TYPE_EVENT ? 'Event' : 'Task';\n\n try {\n $record = $this->getRecord($objectType, $crmProviderId, ['Id', 'IsDeleted']);\n\n return ! empty($record) && ($record['IsDeleted'] ?? false) === false;\n } catch (HttpNotFoundException|HttpBadRequestException) {\n $this->logger->info('[Salesforce] Activity record not found during verification', [\n 'activity' => $activity->getId(),\n 'object_type' => $objectType,\n 'crm_provider_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n });\n }\n\n public function query(string $queryToRun, array $parameters = []): QueryIterator\n {\n // Due to poorly designed external calls, this method cannot be entirely removed\n return $this->queryHandler->query($queryToRun, $parameters);\n }\n\n /*=========== Organization Information ===============*/\n\n /**\n * Get a list of all the API Versions for the instance.\n *\n * @throws CrmException\n *\n * @return mixed\n *\n */\n public function getApiVersions()\n {\n $url = $this->config->crm_base_url . '/services/data';\n\n $response = $this->client->get($url);\n\n return json_decode($response->getBody(), true);\n }\n\n /**\n * Gets the valid recordTypes for a given Salesforce Object via the describe API.\n */\n private function getRecordTypes(string $crmObject): array\n {\n $url = $this->client->getObjectsUrl() . $crmObject . '/describe';\n\n $response = $this->client->get($url);\n $jsonResponse = json_decode($response->getBody(), true);\n\n $fields = [];\n foreach ($jsonResponse['recordTypeInfos'] as $row) {\n $fields[] = ['recordTypeId' => $row['recordTypeId'], 'default' => $row['defaultRecordTypeMapping']];\n }\n\n return $fields;\n }\n\n /**\n * Convert raw field data into a format compatible with CRM APIs.\n */\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return ValueNormalizer::normalize($fieldType, $fieldValue, $internal);\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n $defaultFields = ($activityType === Playbook::ACTIVITY_TYPE_TASK)\n ? FieldDefinitions::defaultTaskFields()\n : FieldDefinitions::defaultEventFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n // Setup the activity field as the default Type.\n /** @var Field $activityField */\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'Type',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK, Playbook::ACTIVITY_TYPE_EVENT];\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n\n $fieldFilter = ($activityType === Playbook::ACTIVITY_TYPE_TASK)\n ? FieldDefinitions::taskFollowupFieldsFilter()\n : FieldDefinitions::eventFollowupFieldsFilter();\n\n foreach ($fieldFilter as $eachFilter) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $eachFilter);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n /**\n * This one is now called only when ImportActivityTypes is triggered or SyncFieldMetadata executed manually\n * Regular sync now uses SharedSyncFieldsTrait -> syncSingleObjectType\n * Needs to be replaced later on\n */\n public function syncField(Field $field): void\n {\n try {\n if (FieldHelper::isCustomField($field)) {\n $query = '\n SELECT\n Id, Metadata, TableEnumOrId\n FROM\n CustomField\n WHERE\n DeveloperName = :fieldName\n AND\n TableEnumOrId = :fieldType\n AND\n NamespacePrefix = :namespacePrefix';\n\n // We need to constrain the field lookup to the object, in case it's used in multiple places.\n $objectType = \\in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true)\n ? 'activity'\n : $field->object_type;\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'fieldName' => substr($field->crm_provider_id, 0, -\\strlen('__c')),\n 'fieldType' => ucfirst($objectType),\n\n // This is used to ensure we only consider the field within the org, not installed packages.\n 'namespacePrefix' => 'null',\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n // Sync field metadata.\n $metadata = $sfField['Metadata'];\n\n $field->description = mb_strimwidth($metadata['description'] ?? '', 0, 191);\n $field->label = mb_strimwidth($metadata['label'] ?? '', 0, Field::LABEL_MAX_LENGTH);\n $field->type = $this->convertFieldType($metadata['type'], $field->getEntityName());\n $field->is_mandatory = ($metadata['required'] === true);\n $field->length = $metadata['length'];\n $field->default_value = mb_strimwidth(trim($metadata['defaultValue'] ?? '', '\"'), 0, 191);\n $field->save();\n } else {\n $query = '\n SELECT\n Id, DataType, DeveloperName, Label, Length, Description\n FROM\n FieldDefinition\n WHERE\n DurableId = :entityName';\n\n $entityName = $field->getEntityName();\n $sfFields = $this->queryHandler->metadata($query, [\n 'entityName' => $entityName,\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n $convertedType = $this->convertFieldType($sfField['DataType'], $entityName);\n $label = mb_strimwidth($sfField['Label'], 0, Field::LABEL_MAX_LENGTH);\n\n if ($field->isBusinessType()) {\n $label = 'Opportunity Type';\n }\n\n $field->description = mb_strimwidth($sfField['Description'], 0, Field::DESCRIPTION_MAX_LENGTH);\n $field->label = $label;\n $field->type = $convertedType;\n $field->length = $sfField['Length'];\n $field->save();\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n private function convertFieldType(string $from, ?string $entityName = null): string\n {\n $converter = new FieldTypeConverter();\n\n return $converter->convert($from, $entityName);\n }\n\n /**\n * @inheritdoc\n */\n public function importPicklistValues(Field $field): array\n {\n $values = [];\n $fieldValues = [];\n\n try {\n if (FieldHelper::isCustomField($field)) {\n $query = '\n SELECT\n Id, Metadata, TableEnumOrId\n FROM\n CustomField\n WHERE\n DeveloperName = :fieldName\n AND\n TableEnumOrId = :fieldType\n AND\n NamespacePrefix = :namespacePrefix';\n\n // We need to constrain the field lookup to the object, in case it's used in multiple places.\n $objectType = \\in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true) ?\n 'activity' : $field->object_type;\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'fieldName' => substr($field->crm_provider_id, 0, -\\strlen('__c')),\n 'fieldType' => ucfirst($objectType),\n // This is used to ensure we only consider the field within the org, not installed packages.\n 'namespacePrefix' => 'null',\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n $valueSet = $sfField['Metadata']['valueSet'];\n\n if ($valueSet['valueSetName'] === null) {\n // Local picklist values can be obtained easily.\n $picklistValues = $valueSet['valueSetDefinition']['value'];\n } else {\n // But for some fields, we just get the Global Value Picklist pointer so need to do more work.\n $picklistValues = $this->importGlobalValuePicklistValues($valueSet['valueSetName']);\n }\n\n // Import all active values.\n foreach ($picklistValues as $i => $sfFieldValue) {\n // Setup default value.\n if ($sfFieldValue['default']) {\n $field->update(['default_value' => $sfFieldValue['valueName']]);\n }\n\n // This comes through as null if active (lol).\n if ($sfFieldValue['isActive'] !== false) {\n $values[] = [\n 'value' => $sfFieldValue['valueName'],\n 'label' => $sfFieldValue['valueName'],\n 'sequence' => $i,\n 'is_default' => $sfFieldValue['default'],\n ];\n }\n }\n } else {\n $objectFields = $this->getObjectFields($field->object_type);\n $fieldId = $field->crm_provider_id;\n\n // Only work with our field of interest.\n $objectField = array_filter($objectFields, function ($item) use ($fieldId) {\n return $item['name'] === $fieldId;\n });\n\n $objectField = array_shift($objectField);\n if (empty($objectField['picklistValues']) === false) {\n foreach ($objectField['picklistValues'] as $i => $sfFieldValue) {\n // Skip inactive values.\n if ($sfFieldValue['active'] === false) {\n continue;\n }\n\n // Setup default value.\n if ($sfFieldValue['defaultValue']) {\n $field->update(['default_value' => $sfFieldValue['value']]);\n }\n\n $values[] = [\n 'value' => $sfFieldValue['value'],\n 'label' => $sfFieldValue['label'],\n 'sequence' => $i,\n 'is_default' => $sfFieldValue['defaultValue'],\n ];\n }\n }\n }\n\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n $value['value'] = substr($value['value'] ?? '', 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n if (($key = array_search($value['value'], $fieldsToPurge, true)) !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n // Get IDs of the values to be deleted\n $valuesToDelete = $field->values()->whereIn('value', $fieldsToPurge);\n $valuesToDeleteIds = $valuesToDelete->pluck('id');\n if (! $valuesToDeleteIds->isEmpty()) {\n $recordTypeFieldValuesRepository = app(RecordTypeFieldValuesRepository::class);\n $recordTypeFieldValuesRepository->deleteForCrmFieldValueIds($valuesToDeleteIds->toArray());\n\n // Now safely delete from crm_field_values\n $valuesToDelete->delete();\n }\n\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n return $fieldValues;\n }\n\n /**\n * Gets values from Global Value Picklists.\n */\n private function importGlobalValuePicklistValues(string $picklistName): array\n {\n $query = '\n SELECT\n Metadata\n FROM\n GlobalValueSet\n WHERE\n DeveloperName = :picklistName\n LIMIT 1';\n\n try {\n $sfValues = $this->queryHandler->metadata($query, [\n 'picklistName' => $picklistName,\n ]);\n\n // There is always 1 result at this point.\n $sfValue = $sfValues->current();\n\n return $sfValue['Metadata']['customValue'];\n } catch (NoResultsException $noResultsException) {\n // Nothing returned.\n\n return [];\n }\n }\n\n /**\n * @inheritdoc\n */\n public function syncProfileRecordTypes(): void\n {\n $objectTypes = [\n 'lead',\n 'account',\n 'contact',\n 'opportunity',\n 'task',\n 'event',\n ];\n\n foreach ($objectTypes as $objectType) {\n try {\n $crmRecordTypes = $this->getRecordTypes(ucfirst($objectType));\n\n foreach ($crmRecordTypes as $crmRecordType) {\n // If the record type is default and not the Master type, set this.\n if ($crmRecordType['default'] && $crmRecordType['recordTypeId'] !== '012000000000000AAA') {\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $crmRecordType['recordTypeId'])\n ->first();\n\n if ($recordType) {\n $this->profile->{$objectType . '_record_type_id'} = $recordType->id;\n }\n }\n }\n } catch (HttpNotFoundException $exception) {\n Log::error('No access to ' . $objectType . ' object, skipping...');\n\n // XXX: should we log this fact somewhere?\n continue;\n }\n }\n\n if ($this->profile->isDirty()) {\n $this->profile->save();\n }\n }\n\n /**\n * Gets business processes.\n */\n public function importBusinessProcesses(): void\n {\n $query = '\n SELECT\n Id, IsActive, Name, TableEnumOrId\n FROM\n BusinessProcess\n WHERE\n TableEnumOrId IN (\\'Lead\\',\\'Opportunity\\')';\n\n try {\n $sfProcesses = $this->queryHandler->query($query);\n\n // Upsert all processes for the team.\n foreach ($sfProcesses as $sfProcess) {\n /** @var BusinessProcess $businessProcess */\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $sfProcess['Id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => $sfProcess['Name'],\n 'type' => $sfProcess['TableEnumOrId'] === 'Lead' ? 'lead' : 'opportunity',\n 'is_selectable' => $sfProcess['IsActive'],\n ]);\n\n $this->importBusinessProcessStages($businessProcess);\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n /**\n * Gets business process stages.\n */\n private function importBusinessProcessStages(BusinessProcess $businessProcess): void\n {\n $query = '\n SELECT\n Metadata\n FROM\n BusinessProcess\n WHERE\n Id = :processId';\n\n try {\n $stages = [];\n $sfProcessStages = $this->queryHandler->metadata($query, [\n 'processId' => $businessProcess->crm_provider_id,\n ]);\n\n // There is always 1 result at this point.\n $sfProcessStage = $sfProcessStages->current();\n\n // Upsert all processes for the team.\n foreach ($sfProcessStage['Metadata']['values'] as $sfProcessStage) {\n $sanitizedName = urldecode($sfProcessStage['valueName']); // Must decode: \"%2C\" becomes \",\" etc.\n\n $stage = $businessProcess->crm->stages()\n // This MUST match on label because this API doesn't use API Name.\n ->where('label', $sanitizedName)\n ->where('type', $businessProcess->type)\n ->where('is_selectable', 1)\n ->first();\n\n if ($stage) {\n $stages[] = $stage->id;\n }\n }\n\n $businessProcess->stages()->sync($stages);\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n /**\n * Gets record types.\n */\n public function importRecordTypes(): void\n {\n $query = '\n SELECT\n Id, IsActive, Name, BusinessProcessId, SobjectType\n FROM\n RecordType';\n\n try {\n $sfRecordTypes = $this->queryHandler->query($query);\n\n // Upsert all record types for the process.\n foreach ($sfRecordTypes as $sfRecordType) {\n $businessProcess = null;\n if ($sfRecordType['BusinessProcessId']) {\n $businessProcess = $this->config->businessProcesses()\n ->where('crm_provider_id', $sfRecordType['BusinessProcessId'])\n ->first();\n }\n\n /** @var RecordType $recordType */\n $recordType = $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $sfRecordType['Id'],\n ], [\n 'team_id' => $this->team->id,\n 'type' => mb_strtolower($sfRecordType['SobjectType']),\n 'name' => $sfRecordType['Name'],\n 'is_selectable' => $sfRecordType['IsActive'],\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n $this->importRecordTypeFieldValues($recordType);\n }\n } catch (NoResultsException $noResultsException) {\n // Do nothing.\n }\n }\n\n /**\n * Import record type - field value mappings. This only works for standard fields.\n */\n private function importRecordTypeFieldValues(RecordType $recordType): void\n {\n try {\n $query = '\n SELECT\n Metadata\n FROM\n RecordType\n WHERE\n Id = :recordTypeId';\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'recordTypeId' => $recordType->crm_provider_id,\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n // Sync field metadata.\n $picklists = $sfField['Metadata']['picklistValues'];\n\n foreach ($picklists as $picklist) {\n $field = $this->config->fields()->where([\n 'type' => Field::TYPE_PICKLIST,\n 'object_type' => $recordType->type,\n 'crm_provider_id' => $picklist['picklist'],\n ])->first();\n\n if ($field) {\n $fieldValues = [];\n\n foreach ($picklist['values'] as $value) {\n // Must decode: \"%2C\" becomes \",\" etc.\n $fieldValue = $field->values()\n ->where('value', urldecode($value['valueName']))\n ->first();\n\n if ($fieldValue) {\n $fieldValues[] = $fieldValue->id;\n }\n }\n\n $recordType->fieldValues()->sync($fieldValues);\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n /**\n * @inheritdoc\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $params = [];\n $missingStage = null;\n if ($types === null) {\n $types = [Stage::TYPE_LEAD, Stage::TYPE_OPPORTUNITY];\n }\n\n foreach ($types as $type) {\n if ($type === Stage::TYPE_LEAD) {\n $query = '\n SELECT\n Id, ApiName, MasterLabel, SortOrder\n FROM\n LeadStatus';\n } else {\n $query = '\n SELECT\n Id, ApiName, MasterLabel, IsActive, SortOrder, DefaultProbability\n FROM\n OpportunityStage';\n }\n\n if ($missingStageName) {\n $escapedStageName = ValueNormalizer::replaceQueryWithStringLiterals($missingStageName);\n\n $query .= ' WHERE ApiName = :stageName';\n\n $params = [\n 'stageName' => $escapedStageName,\n ];\n }\n\n try {\n $sfStages = $this->queryHandler->query($query, $params);\n } catch (NoResultsException $exception) {\n $sfStages = [];\n }\n\n $missingStage = null;\n\n // Upsert all stages for the team.\n foreach ($sfStages as $sfStage) {\n $selectable = true;\n if (array_key_exists('IsActive', $sfStage)) {\n $selectable = $sfStage['IsActive'];\n }\n\n $this->restoreAnyTrashedEntity($this->config->stages(), $sfStage['Id']);\n\n $stage = $this->config->stages()->updateOrCreate([\n 'crm_provider_id' => $sfStage['Id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($sfStage['ApiName'], 0, 50),\n 'label' => mb_strimwidth($sfStage['MasterLabel'], 0, 191),\n 'type' => $type,\n 'sequence' => $sfStage['SortOrder'] ?? 0,\n 'is_selectable' => $selectable,\n 'probability' => $sfStage['DefaultProbability'] ?? null,\n ]);\n\n if ($missingStageName && $missingStageName === $sfStage['ApiName']) {\n $missingStage = $stage;\n }\n }\n\n if ($missingStageName && $missingStage === null) {\n // If they requested a stage that still doesn't exist, it must be inactive so lazy create it.\n $missingStage = $this->config->stages()->create([\n 'crm_provider_id' => Uuid::uuid4(),\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($missingStageName, 0, 50),\n 'label' => mb_strimwidth($missingStageName, 0, 191),\n 'type' => $type,\n 'sequence' => 0,\n 'is_selectable' => 0,\n ]);\n }\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int\n {\n $syncCount = 0;\n $fields = $this->getAllFieldsAsArray('lead');\n if (\\in_array('Id', $fields, true) === false) {\n return $syncCount;\n }\n\n $query = '\n SELECT ' . rtrim(implode(',', $fields), ',') . '\n FROM Lead\n WHERE LastModifiedDate > :since\n ORDER BY LastModifiedDate ASC';\n\n try {\n $sfLeads = $this->queryHandler->query($query, [\n 'since' => $since->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($sfLeads as $sfLead) {\n // Only sync if previously imported.\n if ($this->hasLead($sfLead['Id'])) {\n $this->importLead($sfLead);\n $syncCount++;\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::LEAD);\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLead(string $crmId): ?Lead\n {\n $fields = $this->getAllFieldsAsArray('lead');\n\n $sfLead = $this->getRecord('Lead', $crmId, $fields);\n\n return $this->importLead($sfLead);\n }\n\n private function importLead($crmData): ?Lead\n {\n /** @var ?Stage $stage */\n $stage = null;\n if (isset($crmData['Status'])) {\n // Get the current stage.\n $stage = $this->config\n ->stages()\n ->where('name', $crmData['Status'])\n ->where('type', Stage::TYPE_LEAD)\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_LEAD], $crmData['Status']);\n }\n }\n\n // If we have no way of importing this, just return null :(\n if ($stage === null) {\n return null;\n }\n\n $countryCode = $crmData['CountryCode'] ?? null;\n\n // Salesforce allows custom \"countries\" to be created. Disregard these.\n if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {\n $countryCode = null;\n }\n\n // If we have no country code, try to parse it from the country name.\n if ($countryCode === null && empty($crmData['Country']) !== false) {\n $countryCode = $this->convertCountryNameToCode($crmData['Country']);\n }\n\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($crmData['Phone'] ?? '', 0, 25);\n $parsedNumber = parsePhoneNumber($countryCode, $number);\n\n $mobilePhone = null;\n if (empty($crmData['MobilePhone']) === false) {\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($crmData['MobilePhone'], 0, 25);\n $mobilePhone = phone_e164($countryCode, $number);\n }\n\n $convertedDate = null;\n $convertedAccount = null;\n $convertedOpportunity = null;\n $convertedContact = null;\n\n if ($crmData['IsConverted'] == 'true') {\n $convertedDate = $crmData['ConvertedDate'];\n\n if (empty($crmData['ConvertedAccountId']) === false) {\n $convertedAccount = $this->config\n ->accounts()\n ->where('crm_provider_id', $crmData['ConvertedAccountId'])\n ->first();\n\n if ($convertedAccount === null) {\n try {\n $convertedAccount = $this->syncAccount($crmData['ConvertedAccountId']);\n } catch (HttpNotFoundException $exception) {\n // Probably the user has no permissions to access the converted data.\n }\n }\n }\n\n if (empty($crmData['ConvertedOpportunityId']) === false) {\n $convertedOpportunity = $this->config\n ->opportunities()\n ->where('crm_provider_id', $crmData['ConvertedOpportunityId'])\n ->first();\n\n if ($convertedOpportunity === null) {\n try {\n $convertedOpportunity = $this->syncOpportunity($crmData['ConvertedOpportunityId']);\n } catch (HttpNotFoundException $exception) {\n // Probably the user has no permissions to access the converted data.\n }\n }\n }\n\n if (empty($crmData['ConvertedContactId']) === false) {\n $convertedContact = $this->team\n ->crm\n ->contacts()\n ->where('crm_provider_id', $crmData['ConvertedContactId'])\n ->first();\n\n if ($convertedContact === null) {\n try {\n $convertedContact = $this->syncContact($crmData['ConvertedContactId']);\n } catch (HttpNotFoundException $exception) {\n // Probably the user has no permissions to access the converted data.\n }\n }\n }\n }\n\n if (empty($crmData['Company'])) {\n $company = 'Unknown';\n } else {\n $company = mb_strimwidth($crmData['Company'], 0, 191);\n }\n\n $domain = null;\n if (empty($crmData['Website']) === false) {\n $domain = mb_strimwidth($crmData['Website'], 0, 191);\n $domain = StringUtil::resolveDomain($domain);\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $data = [\n 'team_id' => $this->team->id,\n 'user_id' => $profile?->user_id,\n 'owner_id' => $crmData['OwnerId'] ?? '',\n 'company' => $company,\n 'domain' => $domain,\n 'name' => $crmData['Name'] ? mb_strimwidth($crmData['Name'], 0, 191) : '',\n 'title' => $crmData['Title'] ? mb_strimwidth($crmData['Title'], 0, 128) : null,\n 'email' => $crmData['Email'] ? mb_strimwidth($crmData['Email'], 0, 80) : null,\n 'phone' => $parsedNumber['phone'],\n 'ext' => $parsedNumber['ext'] ?? null,\n 'mobile_phone' => $mobilePhone,\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['Id'],\n modelType: Lead::class,\n fileName: $crmData['Id'],\n avatarText: $crmData['Name']\n ),\n 'stage_id' => $stage->id,\n 'record_type_id' => null,\n 'converted_at' => $convertedDate,\n 'converted_account_id' => $convertedAccount->id ?? null,\n 'converted_opportunity_id' => $convertedOpportunity->id ?? null,\n 'converted_contact_id' => $convertedContact->id ?? null,\n 'country_code' => $countryCode,\n 'remotely_created_at' => $createdDate,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->leads(), $crmData['Id']);\n\n /** @var Lead $lead */\n $lead = $this->config->leads()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n if ($lead->wasChanged('converted_at') && $lead->getConvertedAt() !== null) {\n $this->eventDispatcher->dispatch(new LeadConverted($lead));\n }\n\n $this->handleObjectDeletion($lead, $crmData);\n\n return $lead;\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccounts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n $fields = $this->getAllFieldsAsArray('account');\n\n if (\\in_array('Id', $fields, true) === false) {\n return $syncCount;\n }\n\n $query = '\n SELECT ' . rtrim(implode(',', $fields), ',') . '\n FROM Account\n WHERE LastModifiedDate > :since\n ORDER BY LastModifiedDate ASC';\n\n try {\n $sfAccounts = $this->queryHandler->query($query, [\n 'since' => $since->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($sfAccounts as $sfAccount) {\n // Only sync if previously imported.\n if ($this->hasAccount($sfAccount['Id'])) {\n $this->importAccount($sfAccount);\n $syncCount++;\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::ACCOUNT);\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccount(string $crmId): ?Account\n {\n $fields = $this->getAllFieldsAsArray('account');\n if (! in_array('Id', $fields, true)) {\n $this->logger->info('[Salesforce] Sync account cancelled. Fields are not available.', [\n 'crmId' => $crmId,\n 'userId' => $this->profile->getUserId(),\n ]);\n\n return null;\n }\n\n $sfAccount = $this->getRecord('Account', $crmId, $fields);\n\n return $this->importAccount($sfAccount);\n }\n\n private function importAccount($crmData): Account\n {\n $countryCode = $crmData['BillingCountryCode'] ?? $crmData['ShippingCountryCode'] ?? null;\n\n // Salesforce allows custom \"countries\" to be created. Disregard these.\n if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {\n $countryCode = null;\n }\n\n // If we have no country code, try to parse it from the country names.\n if ($countryCode === null && empty($crmData['BillingCountry']) === false) {\n $countryCode = $this->convertCountryNameToCode($crmData['BillingCountry']);\n }\n\n if ($countryCode === null && empty($crmData['ShippingCountry']) === false) {\n $countryCode = $this->convertCountryNameToCode($crmData['ShippingCountry']);\n }\n\n if (empty($crmData['Phone']) === false) {\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($crmData['Phone'], 0, 25);\n $parsedNumber = parsePhoneNumber($countryCode, $number);\n } else {\n $parsedNumber = [];\n }\n\n $industry = null;\n if (empty($crmData['Industry']) === false) {\n $industry = mb_strimwidth($crmData['Industry'], 0, 40);\n }\n\n $domain = null;\n if (empty($crmData['Website']) === false) {\n $domain = mb_strimwidth($crmData['Website'], 0, 191);\n $domain = StringUtil::resolveDomain($domain);\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $data = [\n 'team_id' => $this->team->id,\n 'user_id' => $profile?->user_id,\n 'owner_id' => $crmData['OwnerId'],\n 'name' => mb_strimwidth($crmData['Name'], 0, 191),\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['Id'],\n modelType: Account::class,\n fileName: $crmData['Id'],\n avatarText: $crmData['Name']\n ),\n 'industry' => $industry,\n 'domain' => $domain,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'ext' => $parsedNumber['ext'] ?? null,\n 'country_code' => $countryCode,\n 'remotely_created_at' => $createdDate,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->accounts(), $crmData['Id']);\n\n /** @var Account $account */\n $account = $this->config->accounts()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n $this->handleObjectDeletion($account, $crmData);\n\n return $account;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOpportunities(array $parameters, ?string $strategy = null): int\n {\n $strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);\n\n $syncCount = 0;\n $logParams = $parameters;\n $parameters['profile'] = $this->profile;\n $logParams['user'] = $this->profile->getUserId();\n\n if (count($strategies) > 1) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Multiple sync strategies used', [\n 'teamId' => $this->team->getUuid(),\n 'params' => $logParams,\n 'strategies_count' => count($strategies),\n ]);\n }\n\n foreach ($strategies as $syncStrategy) {\n $name = $syncStrategy->getStrategyName();\n\n try {\n $sfOpportunities = $syncStrategy->fetchOpportunities($parameters);\n $totalRecords = $sfOpportunities->count();\n\n foreach ($sfOpportunities as $sfOpportunity) {\n $this->importOpportunity($sfOpportunity);\n $syncCount++;\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n $this->logger->warning('[' . $this->getDisplayName() . '] No opportunities found', [\n 'teamId' => $this->team->getUuid(),\n 'name' => $name,\n 'params' => $logParams,\n 'reason' => $noResultsException->getMessage(),\n ]);\n } catch (CrmException $crmException) {\n // Nothing to sync.\n $this->logger->warning('[' . $this->getDisplayName() . '] Opportunity sync failed', [\n 'teamId' => $this->team->getUuid(),\n 'name' => $name,\n 'params' => $logParams,\n 'reason' => $crmException->getMessage(),\n ]);\n }\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::OPPORTUNITY, ['params' => $logParams]);\n\n // debug to see how if count of opportunities reaches 1000\n if ($syncCount >= 1000) {\n $this->logger->info(\n '[' . $this->getDisplayName() . '] Sync Opportunities - count warning',\n [\n 'team_id' => $this->team->getId(),\n 'params' => $logParams,\n 'count' => $syncCount,\n 'strategies_count' => count($strategies),\n 'total_records' => $totalRecords ?? null,\n ]\n );\n }\n\n return $syncCount;\n }\n\n\n /**\n * @inheritdoc\n */\n public function syncOpportunity(string $crmId): ?Opportunity\n {\n $strategy = $this->opportunitySyncStrategyResolver->resolve(\n $this->config,\n OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY\n );\n\n $parameters = [\n 'profile' => $this->profile,\n 'crm_id' => $crmId,\n ];\n\n try {\n $sfOpportunity = $strategy->fetchOpportunities($parameters);\n } catch (HttpNotFoundException $e) {\n $this->logger->info('[' . $this->getDisplayName() . '] Opportunity not found', [\n 'teamId' => $this->team->id_string,\n 'crmId' => $crmId,\n ]);\n\n return null;\n } catch (CrmException $crmException) {\n $this->logger->info('[' . $this->getDisplayName() . '] Opportunity sync failed', [\n 'teamId' => $this->team->id_string,\n 'crmId' => $crmId,\n 'exception' => $crmException->getMessage(),\n ]);\n\n return null;\n }\n\n if ($sfOpportunity instanceof ArrayIterator) {\n return $this->importOpportunity($sfOpportunity->getItems());\n }\n\n return $this->importOpportunity($sfOpportunity);\n }\n\n /**\n * @throws HttpNotFoundException\n */\n private function importOpportunity($crmData): ?Opportunity\n {\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $account = null;\n if (empty($crmData['AccountId']) === false) {\n /** @var ?Account $account */\n $account = $this->config->accounts()\n ->where('crm_provider_id', (string) $crmData['AccountId'])\n ->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmData['AccountId']);\n }\n }\n\n $userId = $profile?->getUserId() ?? $account?->getUserId();\n if ($userId === null) {\n $this->logger->error('[Salesforce] | Skip import, no user_id found', [\n 'id' => $crmData['Id'],\n ]);\n\n return null;\n }\n\n /** @var ?Stage $stage */\n $stage = null;\n if (isset($crmData['StageName'])) {\n $stage = $this->config\n ->stages()\n ->where('name', $crmData['StageName'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->orderBy('is_selectable', 'DESC')\n ->orderBy('id')\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $crmData['StageName']);\n }\n }\n\n $recordType = null;\n if (empty($crmData['RecordTypeId']) === false) {\n /** @var ?RecordType $recordType */\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $crmData['RecordTypeId'])\n ->first();\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $closeDate = null;\n if (empty($crmData['CloseDate']) === false) {\n $closeDate = Carbon::parse($crmData['CloseDate'])->format('Y-m-d');\n }\n\n $valueFieldName = 'Amount';\n if ($this->config->opportunity_value_field_id) {\n $valueFieldName = $this->config->opportunityValueField->crm_provider_id;\n }\n\n $data = [\n 'team_id' => $this->team->id,\n 'account_id' => $account->id ?? null,\n 'user_id' => $userId,\n 'owner_id' => $crmData['OwnerId'] ?? null,\n 'name' => mb_strimwidth($crmData['Name'] ?? '', 0, 128),\n 'value' => $crmData[$valueFieldName],\n 'currency_code' => CurrencyFormatter::formatCode($crmData['CurrencyIsoCode'] ?? null),\n 'close_date' => $closeDate,\n 'is_closed' => $crmData['IsClosed'],\n 'is_won' => $crmData['IsWon'],\n 'stage_id' => $stage?->id ?? null,\n 'record_type_id' => $recordType->id ?? null,\n 'remotely_created_at' => $createdDate,\n 'probability' => $crmData['Probability'] ?? null,\n 'forecast_category' => $crmData['ForecastCategoryName'] ?? null,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->opportunities(), $crmData['Id']);\n\n // Do not allow locked DB tables & other errors\n // to interrupt the process of reverting the trashed opportunities\n try {\n /** @var Opportunity $opportunity */\n $opportunity = $this->config->opportunities()\n ->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n // import external fields into crm_field_data if present\n $crmFields = $this->getOpportunitySyncableFields();\n\n $this->importOpportunityCrmFieldData($crmData, $crmFields, $opportunity->id);\n\n $this->handleObjectDeletion($opportunity, $crmData);\n\n if ($opportunity->wasRecentlyCreated) {\n MatchActivitiesToNewOpportunity::dispatch($opportunity->getId());\n }\n\n return $opportunity;\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n $this->logger->error('[Salesforce] importOpportunity failure.', [\n 'crm_provider_id' => $crmData['Id'],\n 'team_id' => $this->team->id,\n 'exception' => $exception->getMessage(),\n ]);\n\n $this->handleEntityDeletionByProviderId($this->config->opportunities(), $crmData);\n }\n\n return null;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContacts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n $fields = $this->getAllFieldsAsArray('contact');\n if (\\in_array('Id', $fields, true) === false) {\n return $syncCount;\n }\n\n $query = '\n SELECT ' . rtrim(implode(',', $fields), ',') . '\n FROM Contact\n WHERE LastModifiedDate > :since\n ORDER BY LastModifiedDate ASC';\n\n try {\n $sfContacts = $this->queryHandler->query($query, [\n 'since' => $since->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($sfContacts as $sfContact) {\n // Only sync if previously imported.\n if ($this->hasContact($sfContact['Id'])) {\n $this->importContact($sfContact);\n $syncCount++;\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::CONTACT);\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContact(string $crmId): ?Contact\n {\n $fields = $this->getAllFieldsAsArray('contact');\n if (! in_array('Id', $fields, true)) {\n $this->logger->info('[Salesforce] Sync contact cancelled. Fields are not available.', [\n 'crmId' => $crmId,\n 'userId' => $this->profile->getUserId(),\n ]);\n\n return null;\n }\n\n $sfContact = $this->getRecord('Contact', $crmId, $fields);\n\n return $this->importContact($sfContact);\n }\n\n private function importContact($crmData): Contact\n {\n $account = null;\n // Contacts may not have accounts...\n if (isset($crmData['AccountId'])) {\n $account = $this->config->accounts()\n ->where('crm_provider_id', (string) $crmData['AccountId'])\n ->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmData['AccountId']);\n }\n }\n\n $countryCode = $crmData['MailingCountryCode'] ?? null;\n\n // Salesforce allows custom \"countries\" to be created. Disregard these.\n if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {\n $countryCode = null;\n }\n\n // If we have no country code, try to parse it from the country name.\n if ($countryCode === null && empty($crmData['MailingCountry']) === false) {\n $countryCode = $this->convertCountryNameToCode($crmData['MailingCountry']);\n\n if ($countryCode === null && $account) {\n $countryCode = $account->country_code;\n }\n }\n\n $ext = null;\n $parsedNumber = [];\n if (empty($crmData['Phone']) === false) {\n $number = Str::limit($crmData['Phone'], 25, '');\n $parsedNumber = parsePhoneNumber($countryCode, $number);\n\n if (empty($parsedNumber['ext']) === false) {\n $ext = Str::limit($parsedNumber['ext'], 10, '');\n }\n }\n\n $mobileNumber = null;\n if (empty($crmData['MobilePhone']) === false) {\n $mobileNumber = Str::limit(phone_e164($countryCode, $crmData['MobilePhone']), 25, '');\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $data = [\n 'team_id' => $this->team->id,\n 'account_id' => $account->id ?? null,\n 'user_id' => $profile?->user_id,\n 'owner_id' => $crmData['OwnerId'] ?? null,\n 'name' => ($crmData['Name'] ?? null) !== null ? mb_strimwidth($crmData['Name'], 0, 100) : '',\n 'title' => ($crmData['Title'] ?? null) !== null ? mb_strimwidth($crmData['Title'], 0, 128) : null,\n 'email' => ($crmData['Email'] ?? null) !== null ? mb_strimwidth($crmData['Email'], 0, 191) : null,\n 'country_code' => $countryCode,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'ext' => $ext,\n 'mobile_phone' => $mobileNumber,\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['Id'],\n modelType: Contact::class,\n fileName: $crmData['Id'],\n avatarText: $crmData['Name']\n ),\n 'remotely_created_at' => $createdDate,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->contacts(), $crmData['Id']);\n\n /** @var Contact $contact */\n $contact = $this->config->contacts()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n $this->handleObjectDeletion($contact, $crmData);\n\n return $contact;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n $fields = [\n 'InstanceName',\n 'OrganizationType',\n 'IsSandbox',\n ];\n\n $orgValues = $this->getRecord('Organization', $this->config->crm_provider_id, $fields);\n\n $edition = null;\n switch ($orgValues['OrganizationType']) {\n case 'Developer Edition':\n $edition = Configuration::EDITION_DEVELOPER;\n\n break;\n\n case 'Professional Edition':\n $edition = Configuration::EDITION_PROFESSIONAL;\n\n break;\n\n case 'Enterprise Edition':\n $edition = Configuration::EDITION_ENTERPRISE;\n\n break;\n }\n\n $this->config->edition = $edition;\n $this->config->instance = $orgValues['InstanceName'];\n\n // XXX: How can this state be possible?\n if ($this->config->version === null) {\n $this->config->version = Client::MIN_API_VERSION;\n }\n\n $installedVersion = $this->getInstalledAppVersion();\n if ($installedVersion !== null) {\n $installedVersion = (string) $this->getInstalledAppVersion();\n }\n\n $this->config->installed_app_version = $installedVersion;\n\n $this->config->save();\n }\n\n public function getInstalledAppVersion(): ?string\n {\n try {\n $query = '\n SELECT\n SubscriberPackageVersion.MajorVersion,\n SubscriberPackageVersion.MinorVersion,\n SubscriberPackageVersion.PatchVersion,\n SubscriberPackageVersion.BuildNumber\n FROM\n InstalledSubscriberPackage\n WHERE\n SubscriberPackageId = :packageId\n ';\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'packageId' => self::INSTALLED_PACKAGE_ID,\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n // Grab version number.\n $version = $sfField['SubscriberPackageVersion']['MajorVersion'] .\n $sfField['SubscriberPackageVersion']['MinorVersion'] .\n $sfField['SubscriberPackageVersion']['PatchVersion'] .\n $sfField['SubscriberPackageVersion']['BuildNumber'];\n } catch (\\Exception) {\n $version = null;\n }\n\n return $version;\n }\n\n /**\n * Store transcripts as note.\n *\n * @throws \\Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // For SF we also check if Log Notes is enabled.\n if ($this->profile->log_notes === Profile::LOG_NOTE_NONE) {\n return;\n }\n\n if ($activity->opportunity_id && $activity->prospect === null) {\n return;\n }\n\n try {\n $transcriptionData = $this->generateTranscription($activity);\n\n $noteMaxLength = $this->profile->log_notes === Profile::LOG_NOTE_ENHANCED\n ? self::ENHANCED_NOTE_MAX_LENGTH\n : self::CLASSIC_NOTE_MAX_LENGTH;\n\n $title = 'Transcript for ';\n $title .= $activity->title ?? $activity->activity_title;\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $body = mb_strimwidth($transcriptionData, 0, $noteMaxLength);\n\n if ($activity->opportunity_id) {\n $objectId = $activity->opportunity->crm_provider_id;\n } else {\n $objectId = $activity->prospect->crm_provider_id;\n }\n\n $noteId = $this->saveNote($title, $body, $objectId);\n\n // Store crm logged id in transcription.\n $transcription = $activity->getTranscription();\n $transcription->crm_activity_id = $noteId;\n $transcription->save();\n } catch (\\Exception $e) {\n \\Sentry::captureException($e);\n }\n }\n\n public function saveNote(string $title, string $body, string $objectId, ?NoteObject $noteObject = null): ?string\n {\n $noteId = null;\n\n try {\n if ($this->profile->log_notes === Profile::LOG_NOTE_ENHANCED) {\n $noteId = $this->buildEnhancedNote($title, $body, $objectId);\n } else {\n $noteId = $this->buildClassicNote($title, $body, $objectId);\n }\n } catch (HttpNotFoundException $exception) {\n // The profile not having access to create Enhanced Notes. Set their preference to Classic.\n if ($this->profile->log_notes === Profile::LOG_NOTE_ENHANCED) {\n $this->profile->update([\n 'log_notes' => Profile::LOG_NOTE_CLASSIC,\n ]);\n }\n }\n\n return $noteId;\n }\n\n /**\n * This is using the \"Enhanced\" Notes feature, NOT the \"Notes & Attachments\" feature being deprecated.\n *\n * @url https://salesforce.stackexchange.com/questions/104408/how-can-i-create-an-account-note-or-contact-note-via-api-that-is-visible-in-sale\n */\n private function buildEnhancedNote(string $title, string $body, string $objectId): string\n {\n // Decode stored entities, escape HTML (without quoting), then convert line breaks for Salesforce formatting\n $decodedBody = html_entity_decode($body, ENT_QUOTES | ENT_HTML5);\n $sanitizedBody = htmlspecialchars($decodedBody, ENT_NOQUOTES, 'UTF-8', false);\n $content = nl2br($sanitizedBody, false);\n $note = [\n 'OwnerId' => $this->profile->crm_provider_id,\n 'Title' => $title,\n 'Content' => base64_encode($content),\n ];\n\n $noteId = $this->createRecord('ContentNote', $note);\n\n $link = [\n 'ContentDocumentId' => $noteId,\n 'LinkedEntityId' => $objectId,\n 'ShareType' => 'I',\n ];\n\n $this->createRecord('ContentDocumentLink', $link);\n\n return $noteId;\n }\n\n private function buildClassicNote(string $title, string $body, string $objectId): string\n {\n if (in_array($this->parseObjectType($objectId), [Field::OBJECT_TASK, Field::OBJECT_EVENT])) {\n $this->logger->info('[Salesforce] Summary not sent', [\n 'profile_id' => $this->profile->id,\n 'objectId' => $objectId,\n 'reason' => 'Classical Note does not support Task/Event relation',\n ]);\n\n return '';\n }\n\n $titleTrimmed = null;\n\n if (mb_strlen($title) > 80) {\n $titleTrimmed = substr($title, 0, 77) . '...';\n }\n $payload = [\n 'OwnerId' => $this->profile->crm_provider_id,\n 'IsPrivate' => false,\n 'Title' => $titleTrimmed ?? $title,\n 'Body' => $titleTrimmed ? $title . PHP_EOL . $body : $body,\n 'ParentId' => $objectId,\n ];\n\n return $this->createRecord('Note', $payload);\n }\n\n /**\n * @inheritdoc\n */\n public function find(string $name, array $scopes): array\n {\n if ($this->profile === null) {\n return [];\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $limitValues = ['limit' => $this->limit, 'offset' => $this->offset];\n $sosl = $queryBuilder->buildFindQuery($name, $scopes, $limitValues);\n\n $this->logger->info('[Salesforce] Find prospects', [\n 'profile_id' => $this->profile->id,\n 'sosl_query' => $sosl,\n 'search_string' => $name,\n 'scopes' => $scopes,\n ]);\n\n $data = Cache::remember($this->profile->id . $sosl, self::CACHE_TTL, function () use ($sosl) {\n $data = [];\n\n try {\n // Hit remote API.\n $objects = $this->queryHandler->search($sosl);\n\n // Build mapped list.\n foreach ($objects as $object) {\n $type = strtolower($object['attributes']['type']);\n\n $record = [\n 'crmId' => $object['Id'],\n 'name' => $object['Name'],\n 'prospectType' => $type,\n 'phoneNumbers' => [],\n 'crmUrl' => $this->generateProviderUrl($object['Id'], $type),\n ];\n\n switch ($type) {\n case 'lead':\n if (empty($object['Company']) === false) {\n $record['organization'] = $object['Company'];\n }\n\n if (empty($object['Title']) === false) {\n $record['title'] = $object['Title'];\n }\n\n $stage = $this->config->stages()\n ->where('type', Stage::TYPE_LEAD)\n ->where('name', $object['Status'])\n ->first();\n\n // Lazy create the stage.\n if ($stage === null) {\n $stage = $this->importStages([Stage::TYPE_LEAD], $object['Status']);\n }\n\n if ($stage) {\n $record += [\n 'stage' => [\n 'id' => $stage->id_string,\n 'name' => $stage->name,\n ],\n ];\n }\n\n if (empty($object['RecordTypeId']) === false) {\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $object['RecordTypeId'])\n ->first();\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n }\n\n break;\n\n case 'account':\n if (empty($object['Industry']) === false) {\n $record['industry'] = $object['Industry'];\n $record['detailsLine'] = $object['Industry'];\n }\n if (! empty($object['PersonEmail'])) {\n $record['detailsLine'] = $object['PersonEmail'];\n }\n\n break;\n\n case 'contact':\n // For contacts, we should try and fetch their account name too.\n if ($object['AccountId']) {\n // Cheaper to get this locally.\n $account = $this->config->accounts()\n ->where('crm_provider_id', $object['AccountId'])\n ->first(['name']);\n\n if ($account) {\n $record['organization'] = $account->name;\n }\n }\n\n if (! empty($object['IsPersonAccount']) && $object['Email']) {\n $record['detailsLine'] = $object['Email'];\n } else {\n if (empty($object['Title']) === false) {\n $record['title'] = $object['Title'];\n }\n }\n\n break;\n }\n\n // Add phone numbers to record.\n if (empty($object['Phone']) === false && $object['Phone']) {\n $record['phoneNumbers'][] = [\n 'number' => $object['Phone'],\n 'nationalFormat' => phone_national($this->profile->user->country_code, $object['Phone']),\n 'type' => 'phone',\n ];\n }\n\n if (empty($object['MobilePhone']) === false && $object['MobilePhone']) {\n $record['phoneNumbers'][] = [\n 'number' => $object['MobilePhone'],\n 'nationalFormat' => phone_national(\n $this->profile->user->country_code,\n $object['MobilePhone']\n ),\n 'type' => 'mobile',\n ];\n }\n\n $data[] = $record;\n }\n } catch (NoResultsException $e) {\n $data = [];\n }\n\n return $data;\n });\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n\n if ($crmAccountId === null) {\n return $data;\n }\n\n if ($userId) {\n $profileRepository = app(ProfileRepository::class);\n $profile = $profileRepository->findProfileByUserId($this->config, $userId);\n\n $ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;\n }\n\n try {\n // Perhaps their profile has no opportunity permissions.\n if ($this->profile === null || $this->profile->opportunity_fields === null) {\n return $data;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $query = $queryBuilder->buildFindOpportunitiesQuery();\n\n $objects = $this->queryHandler->query($query, ['accountId' => $crmAccountId]);\n\n foreach ($objects as $object) {\n $record = [\n 'crmId' => $object['Id'],\n 'name' => $object['Name'],\n 'won' => $object['IsWon'],\n 'closed' => $object['IsClosed'],\n ];\n\n $valueFieldName = 'Amount';\n if ($this->config->opportunity_value_field_id) {\n $valueFieldName = $this->config->opportunityValueField->crm_provider_id;\n }\n\n if (empty($object[$valueFieldName]) === false) {\n $currency = $object['CurrencyIsoCode'] ?? $this->config->default_currency;\n $value = formatCurrency($object[$valueFieldName], $currency);\n\n $record += [\n 'value' => $value,\n ];\n }\n\n $stage = $this->config->stages()\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->where('name', $object['StageName'])\n ->first();\n\n // Lazy create the stage.\n if ($stage === null) {\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $object['StageName']);\n }\n\n $record += [\n 'stage' => [\n 'id' => $stage->id_string,\n 'name' => $stage->name,\n ],\n ];\n\n if (empty($object['RecordTypeId']) === false) {\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $object['RecordTypeId'])\n ->first();\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n }\n\n if ($ownerId && isset($object['OwnerId']) && $object['OwnerId'] === $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n } catch (NoResultsException $e) {\n return $data;\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n public function getContactRolesFromCrm(?Carbon $since = null): array\n {\n $roles = [];\n\n if ($this->profile === null) {\n return $roles;\n }\n\n $queryBuilder = app(QueryBuilder::class, ['profile' => $this->profile]);\n\n $query = $queryBuilder->buildGetContactRolesQuery($since);\n\n try {\n $objects = $this->queryHandler->query($query);\n\n foreach ($objects as $object) {\n $roles[] = [\n 'id' => $object['Id'],\n 'contactId' => $object['ContactId'],\n 'opportunityId' => $object['OpportunityId'],\n 'ownerId' => $object['Opportunity']['OwnerId'] ?? null,\n 'isPrimary' => $object['IsPrimary'],\n 'role' => $object['Role'],\n ];\n }\n } catch (NoResultsException) {\n // Just return an empty array.\n $this->logger->info('[Salesforce] No contact roles found', [\n 'since' => $since?->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n }\n\n return $roles;\n }\n\n public function syncContactRoles(Carbon $since): int\n {\n $contactRoleRepository = app(ContactRoleRepository::class);\n\n $crmContactRoles = $this->getContactRolesFromCrm(since: $since);\n $syncCount = 0;\n $contactRoles = [];\n\n foreach ($crmContactRoles as $crmContactRole) {\n $contactRoles[] = $this->importContactRole($crmContactRole);\n $syncCount++;\n }\n\n $contactRoleRepository->saveContactRoles($contactRoles);\n\n $this->syncRemotelyDeletedContactRoles();\n\n return $syncCount;\n }\n\n private function importContactRole(array $contactRole): array\n {\n $contact = $this->config->contacts()\n ->where('crm_provider_id', $contactRole['contactId'])\n ->first();\n\n if ($contact === null) {\n $contact = $this->syncContact($contactRole['contactId']);\n }\n\n $opportunity = $this->config->opportunities()\n ->where('crm_provider_id', $contactRole['opportunityId'])\n ->first();\n\n if ($opportunity === null) {\n $opportunity = $this->syncOpportunity($contactRole['opportunityId']);\n }\n\n $role = null;\n if (! empty($contactRole['role'])) {\n $role = mb_strimwidth($contactRole['role'], 0, 191);\n }\n\n return [\n 'crm_configuration_id' => $this->config->getId(),\n 'contact_id' => $contact->getId(),\n 'crm_provider_id' => $contactRole['id'],\n 'subject_type' => ContactRole::SUBJECT_TYPE_OPPORTUNITY,\n 'subject_id' => $opportunity->getId(),\n 'is_primary' => $contactRole['isPrimary'],\n 'role' => $role,\n ];\n }\n\n protected function syncRemotelyDeletedContactRoles(): bool\n {\n try {\n $deletedRemotely = $this->queryHandler->queryDeleted('OpportunityContactRole');\n } catch (NoResultsException $e) {\n return false;\n }\n\n $deletedOpportunities = $deletedRemotely->getResults();\n $deletedIds = array_column($deletedOpportunities, 'id');\n\n $contactRoleRepository = app(ContactRoleRepository::class);\n\n foreach (array_chunk($deletedIds, self::HARD_DELETE_CHUNK) as $chunk) {\n $contactRoleRepository->deleteContactRoles($chunk);\n\n $this->logger->info('[' . $this->getDisplayName() . '] Remotely deleted opportunities synced', [\n 'teamId' => $this->team->id_string,\n 'remotelyDeletedOpportunities' => $chunk,\n 'count' => count($chunk),\n ]);\n }\n\n return true;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = $objects = [];\n\n $hasWho = \\in_array($objectType, ['lead', 'contact']);\n $playbook = $this->getPlaybook($this->profile->user);\n $fields = array_merge(\n $this->profile->getFieldsAsArray(parent::OBJECT_TASK),\n $playbook && $playbook->activityField ? [$playbook->activityField->crm_provider_id] : []\n );\n\n // Query should default to any open call for that user.\n $query = '\n SELECT ' . implode(',', array_unique($fields)) . '\n FROM Task\n WHERE OwnerId = :ownerId\n AND IsArchived = false\n AND IsDeleted = false\n AND IsClosed = false\n AND (';\n\n if ($objectType === 'account') {\n // This covers tasks tied to a related contact or opportunity too.\n $query .= '\n AccountId = :accountId';\n }\n\n if ($hasWho) {\n $query .= '\n WhoId = :whoId';\n\n // If we are also going to check on a specific opportunity, set that up.\n if ($opportunityId) {\n $query .= ' OR WhatId = :whatId';\n }\n }\n\n $query .= ' ) ORDER BY LastModifiedDate DESC';\n\n try {\n $objects = $this->queryHandler->query($query, [\n 'ownerId' => $this->profile->crm_provider_id,\n 'whoId' => $objectId,\n 'whatId' => $opportunityId,\n 'accountId' => $objectId,\n ]);\n } catch (NoResultsException $e) {\n return $data;\n } finally {\n $this->logger->debug(sprintf('[Salesforce] Found %s tasks for query \"%s\"', count($objects), $query));\n }\n\n foreach ($objects as $object) {\n $dueDate = $object['ActivityDate'] ? Carbon::parse($object['ActivityDate'])->toIso8601String() : null;\n $data[] = [\n 'crmId' => $object['Id'],\n 'subject' => $object['Subject'],\n 'due' => $dueDate,\n 'type' => $object[$playbook->activityField->crm_provider_id],\n ];\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getEvents(string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = $objects = [];\n $user = $this->profile?->user;\n if ($this->profile === null || $user === null) {\n return $data;\n }\n\n $hasWho = \\in_array($objectType, ['lead', 'contact']);\n $playbook = $this->getPlaybook($user);\n $fields = array_merge(\n $this->profile->getFieldsAsArray(parent::OBJECT_EVENT),\n $playbook && $playbook->activityField ? [$playbook->activityField->crm_provider_id] : []\n );\n\n // Query should default to any event starting in the last week and ending up until today owned by the user.\n $query = '\n SELECT ' . implode(',', array_unique($fields)) . '\n FROM Event\n WHERE OwnerId = :ownerId\n AND IsArchived = false\n AND IsAllDayEvent = false\n AND StartDateTime >= LAST_N_DAYS:7\n AND EndDateTime <= TODAY\n AND (';\n\n if ($objectType === 'account') {\n // This covers events tied to a related contact or opportunity too.\n $query .= '\n AccountId = :accountId';\n }\n\n if ($hasWho) {\n $query .= '\n WhoId = :whoId';\n\n // If we are also going to check on a specific opportunity, set that up.\n if ($opportunityId) {\n $query .= ' OR WhatId = :whatId';\n }\n }\n\n $query .= ' ) ORDER BY LastModifiedDate DESC';\n\n try {\n $objects = $this->queryHandler->query($query, [\n 'ownerId' => $this->profile->crm_provider_id,\n 'whoId' => $objectId,\n 'whatId' => $opportunityId,\n 'accountId' => $objectId,\n ]);\n } catch (NoResultsException $e) {\n return $data;\n } finally {\n $this->logger->debug(sprintf('[Salesforce] Found %s tasks for query \"%s\"', count($objects), $query));\n }\n\n foreach ($objects as $object) {\n $dueDate = $object['StartDateTime'] ? Carbon::parse($object['StartDateTime'])->toIso8601String() : null;\n\n $data[] = [\n 'crmId' => $object['Id'],\n 'subject' => $object['Subject'],\n 'due' => $dueDate,\n 'type' => $object[$playbook->activityField->crm_provider_id],\n ];\n }\n\n return $data;\n }\n\n /**\n * Try to find CRM Objects using email address\n *\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchExactlyByEmail(string $email, ?int $userId = null): ?array\n {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $sosl = $queryBuilder->buildMatchByQuery($email, Field::TYPE_EMAIL);\n if ($sosl === null) {\n return null;\n }\n\n try {\n $objects = $this->queryHandler->search($sosl);\n $objects = $this->queryHandler->prioritiseResults(\n $objects,\n $email,\n QueryHandler::PRIORITISE_EMAIL\n );\n\n $data = $this->convertCrmData($objects, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (NoResultsException $e) {\n // Try the account next.\n if ($this->profile->account_fields === null) {\n return null;\n }\n }\n\n return null;\n }\n\n public function getDomain(string $email): ?string\n {\n // SF improved search - strip the domain extension, min domain name length 4\n return $this->getCompanyNameFromEmail(email: $email, minNameLength: 4);\n }\n\n /**\n * Try to find CRM objects using domain name of the email address\n *\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByDomain(string $domain, ?int $userId = null): ?array\n {\n $companyName = $domain;\n\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $sosl = $queryBuilder->buildMatchByDomainQuery($companyName);\n\n try {\n $objects = $this->queryHandler->search($sosl);\n\n $data = $this->convertCrmData($objects, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (NoResultsException) {\n return null;\n }\n }\n\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n // Don't bother looking up numbers that are masked.\n if (str_contains($phone, '**')) {\n return null;\n }\n\n if ($this->isPhoneNumberOfTeamMember($phone)) {\n return null;\n }\n\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $phoneNational = phone_national(null, $phone) ?? '';\n $possiblePhoneFormats = collect([\n preg_replace('/\\D/', '', ltrim($phone, '0+')),\n preg_replace('/\\D/', '', $phoneNational),\n formatDashPhoneNumber($phone),\n $phoneNational,\n ])\n ->filter() // Removes null and empty strings\n ->unique()\n ->values();\n\n foreach ($possiblePhoneFormats as $phone) {\n $sosl = $queryBuilder->buildMatchByQuery($phone, Field::TYPE_PHONE);\n if ($sosl === null) {\n continue;\n }\n\n try {\n $objects = $this->queryHandler->search($sosl);\n $objects = $this->queryHandler->prioritiseResults(\n $objects,\n $phone,\n QueryHandler::PRIORITISE_PHONE\n );\n\n return $this->convertCrmData($objects, $userId);\n } catch (NoResultsException) {\n continue;\n }\n }\n\n return null;\n }\n\n private function isPhoneNumberOfTeamMember(string $phone): bool\n {\n $teamRepository = app(TeamRepository::class);\n $user = $teamRepository->findTeamMemberByPhone($this->team, $phone);\n\n if ($user instanceof User) {\n return true;\n }\n\n return false;\n }\n\n protected function getCacheKey(string $object, ?int $userId = null): ?string\n {\n $key = $this->profile->id . $object;\n $keySuffix = $this->getOwnerKeySuffix($userId);\n\n return $key . $keySuffix;\n }\n\n private function getOwnerKeySuffix(?int $userId = null): string\n {\n return $userId === null ? '' : (string) $userId;\n }\n\n /** Determine the CRM Objects which represent the call activity. */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n if ($this->profile === null) {\n return null;\n }\n\n $cacheKey = $this->getCacheKey($name, $userId);\n\n $result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $sosl = $queryBuilder->buildMatchByQuery($name, 'name');\n if ($sosl === null) {\n return false;\n }\n\n try {\n $objects = $this->queryHandler->search($sosl);\n } catch (NoResultsException $e) {\n return false;\n }\n\n $objects = $this->queryHandler->prioritiseResults(\n $objects,\n $name,\n QueryHandler::PRIORITISE_NAME\n );\n\n $data = $this->convertCrmData($objects, $userId);\n\n return (! empty(array_filter($data))) ? $data : false;\n });\n\n return is_array($result) ? $result : null;\n }\n\n /**\n * @return array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n protected function convertCrmData(QueryIterator $objects, ?int $userId = null): array\n {\n $lead = null;\n $contact = null;\n $opportunity = null;\n $account = null;\n $stage = null;\n $countryCode = null;\n\n if ($objects->count() > 0) {\n $object = $objects->current();\n\n if ($object['attributes']['type'] === 'Lead') {\n $lead = $this->importLead($object);\n\n // Lead might not be imported if the Stage is null for example.\n if ($lead) {\n $countryCode = $lead->country_code;\n $stage = $lead->stage;\n }\n } else {\n if ($object['attributes']['type'] === 'Contact') {\n $contact = $this->importContact($object);\n $account = $contact->account;\n } else {\n $account = $this->importAccount($object);\n }\n\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account) {\n $countryCode = $account->country_code;\n }\n\n try {\n $sfOpportunities = $this->findOpportunities(\n $account?->getCrmProviderId(),\n $contact?->getCrmProviderId(),\n $userId\n );\n\n // Take the first opportunity, which will be ordered as priority based on their settings.\n if (! empty($sfOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($sfOpportunities[0]['crmId']);\n $stage = $opportunity?->stage;\n }\n } catch (Exception) {\n // Nothing to see here.\n }\n }\n }\n\n return [\n $lead,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n /**\n * @inheritdoc\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n if ($stage->type === Stage::TYPE_LEAD) {\n $objectType = 'Lead';\n $objectId = $crmObject->crm_provider_id;\n $objectStageType = 'Status';\n } else {\n $objectType = 'Opportunity';\n $objectId = $crmObject->crm_provider_id;\n $objectStageType = 'StageName';\n }\n\n $headers = [];\n if ($this->config->trigger_assignment_rules === false) {\n // @see: https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/headers_autoassign.htm\n $headers = [\n 'Sforce-Auto-Assign' => 'false',\n ];\n }\n\n $this->updateRecord($objectType, $objectId, [$objectStageType => $stage->name], $headers);\n }\n\n public function parseObjectType(string $objectId): string\n {\n if (Str::startsWith($objectId, '001')) {\n return 'account';\n }\n\n if (Str::startsWith($objectId, '003')) {\n return 'contact';\n }\n\n if (Str::startsWith($objectId, '00Q')) {\n return 'lead';\n }\n\n if (Str::startsWith($objectId, '006')) {\n return 'opportunity';\n }\n\n if (Str::startsWith($objectId, '00U')) {\n return 'event';\n }\n\n if (Str::startsWith($objectId, '00T')) {\n return 'task';\n }\n\n throw new \\InvalidArgumentException('Unsupported Object Type');\n }\n\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, ['profile' => $this->profile]);\n $query = $queryBuilder->buildGetUsersQuery($userToSearch);\n\n try {\n $salesforceUsers = $this->queryHandler->query($query, [\n 'active' => true,\n ]);\n } catch (NoResultsException $e) {\n $this->logger->info('[Salesforce] Sync Profiles. No users found', [\n 'query' => $query,\n 'error' => $e->getMessage(),\n ]);\n\n return null;\n }\n\n $teamRepository = app(TeamRepository::class);\n $customRules = $this->getCustomProfileRules($teamRepository);\n\n foreach ($salesforceUsers as $crmUser) {\n if ($crmUser['Email'] === null) {\n continue;\n }\n\n if (! $this->customProfileValidation($crmUser, $customRules)) {\n continue;\n }\n\n $user = $teamRepository->findActiveTeamMemberByEmail($this->team, $crmUser['Email']);\n\n if (! $user instanceof User) {\n continue;\n }\n\n $edition = $crmUser['UserPreferencesLightningExperiencePreferred']\n ? Profile::EDITION_LIGHTNING\n : Profile::EDITION_CLASSIC;\n\n $profileRepository = app(ProfileRepository::class);\n $profile = $profileRepository->updateOrCreateProfile(\n $user,\n [\n 'crm_configuration_id' => $this->config->getId(),\n 'crm_provider_id' => $crmUser['Id'],\n ],\n [\n 'user_id' => $user->getId(),\n 'edition' => $edition,\n 'has_external_cti' => ! empty($crmUser['CallCenterId']),\n 'crm_profile_id' => $crmUser['ProfileId'],\n ]\n );\n\n if ($userToSearch instanceof User && $userToSearch->getId() === $user->getId()) {\n return $profile;\n }\n }\n\n // Clean up inactive profiles\n try {\n $this->archiveInactiveProfiles();\n } catch (\\Exception $e) {\n $this->logger->warning('[Salesforce] Profile archiving failed', [\n 'teamId' => $this->team->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $url = null;\n\n // For Salesforce it's easy, we just point every object to the apex domain and they handle it.\n switch ($objectType) {\n case 'lead':\n case 'account':\n case 'contact':\n case 'opportunity':\n case 'task':\n case 'event':\n case 'activity':\n\n $url = $this->config->crm_base_url . '/' . $providerId;\n\n break;\n }\n\n return $url;\n }\n\n public function buildTaskSearchFields(): array\n {\n return ['Id', 'WhoId', 'WhatId', 'AccountId'];\n }\n\n public function getTaskByFilterConditions(\n array $fields,\n array $filters,\n bool $bulkSearch = false,\n bool $strictFilters = true\n ): ?array {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $query = $queryBuilder->buildSearchTaskQuery($fields, $filters, $bulkSearch, $strictFilters);\n\n try {\n if (! $bulkSearch) {\n $objects = $this->queryHandler->query($query, $filters);\n if ($objects->count() === 1) {\n return $objects->current();\n }\n }\n\n if ($bulkSearch) {\n $objects = $this->queryHandler->query($query);\n $records = [];\n foreach ($objects as $record) {\n $key = $record[end($fields)];\n $records[$key] = $record;\n }\n\n return $records;\n }\n } catch (\\Exception $e) {\n $this->logger->info('[Salesforce] Failed to execute query', [\n 'query' => $query,\n 'error' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function mapCrmObjects(array $task): array\n {\n $activityData = [];\n\n if (! empty($task['WhoId'])) {\n $type = $this->parseObjectType($task['WhoId']);\n $activityData[$type] = $task['WhoId'];\n }\n if (! empty($task['AccountId'])) {\n $activityData['account'] = $task['AccountId'];\n }\n if (! empty($task['WhatId'])) {\n $activityData['opportunity'] = $task['WhatId'];\n }\n\n return $activityData;\n }\n\n /**\n * Get SF task by Outreach call id.\n */\n public function getTaskByFilter(\n string $activityFieldType,\n array $filters,\n string $operator = '=',\n array $additionalFields = []\n ): ?array {\n $data = [];\n\n try {\n // Default (base) fields.\n $fields = ['Id', 'Subject', 'Description', 'ActivityDate', 'WhoId', 'WhatId', $activityFieldType];\n\n foreach ($additionalFields as $additionalField) {\n $fields[] = $additionalField->crm_provider_id;\n }\n\n $fields = array_unique($fields);\n\n // Find task with the same Outreach id as the call id.\n $query = 'SELECT ' . implode(',', $fields) . '\n FROM Task\n WHERE IsArchived = false AND IsDeleted = false';\n\n foreach ($filters as $key => $value) {\n $key = preg_quote($key, '/');\n $key = str_replace(['\\'', '\"'], '', $key);\n // Prepare the substitution.\n $strKey = \":$key\";\n\n $query .= \" AND $key $operator $strKey\";\n }\n\n $query .= ' ORDER BY LastModifiedDate DESC LIMIT 1';\n\n $objects = $this->queryHandler->query($query, $filters);\n\n // There should be only one task related to this call if any.\n if ($objects->count() === 1) {\n $object = $objects->current();\n\n $dueDate = $object['ActivityDate'] ? Carbon::parse($object['ActivityDate'])->toIso8601String() : null;\n\n $data = array_merge($object, [\n 'crmId' => $object['Id'],\n 'subject' => $object['Subject'],\n 'summary' => $object['Description'],\n 'due' => $dueDate,\n 'Type' => $object[$activityFieldType],\n ]);\n }\n } catch (NoResultsException $e) {\n // Filters don't match any records.\n } catch (ServiceUnavailableException $serviceUnavailableException) {\n // Service cannot be queried. We should probably log this.\n }\n\n return $data;\n }\n\n /**\n * Get Salesforce fields including datetime fields\n *\n * @param $objectType\n */\n private function getAllFieldsAsArray($objectType): array\n {\n $basicFields = [];\n // Not all users have access to all object fields.\n if ($this->profile->{$objectType . '_fields'}) {\n $basicFields = explode(',', $this->profile->{$objectType . '_fields'});\n }\n\n $extraFields = [\n 'CreatedDate',\n 'LastModifiedDate',\n 'IsDeleted',\n ];\n\n if ($objectType === self::OBJECT_OPPORTUNITY\n && $this->config->opportunity_value_field_id\n && ! in_array($this->config->opportunityValueField->crm_provider_id, $basicFields)\n ) {\n $extraFields[] = $this->config->opportunityValueField->crm_provider_id;\n }\n\n return array_unique(array_merge($basicFields, $extraFields));\n }\n\n /**\n * Generate transcription for activity description.\n */\n private function generateTranscription(Activity $activity): string\n {\n if (! ($this->config->store_transcript)) {\n // If sending transcription to activity toggle is disabled\n return '';\n }\n\n return $this->transcriptionService\n ->findTranscriptionByActivity($activity)\n ->map(static function (array $transcriptionSegment): string {\n return $transcriptionSegment['formattedStartsAt'] . ' | ' . $transcriptionSegment['transcript'];\n })\n ->implode(PHP_EOL);\n }\n\n /**\n * Find related Salesforce event based on activity data\n *\n * @return array<string>\n */\n public function fetchRelatedActivity(Activity $activity): array\n {\n $this->logger->info('[Salesforce] Searching for related activity', [\n 'activityId' => $activity->getUuid(),\n 'ownerId' => $this->profile?->crm_provider_id,\n ]);\n\n $sfEvent = $this->fetchRelatedEvent($activity);\n if (empty($sfEvent)) {\n $this->logger->info('[Salesforce] No related activity found', [\n 'activityId' => $activity->getUuid(),\n 'ownerId' => $this->profile?->crm_provider_id,\n 'account' => $activity->hasAccount()\n ? $activity->getAccount()->getCrmProviderId()\n : null,\n ]);\n\n return [];\n }\n\n return $sfEvent;\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n if ($activity->isTypeConference() === false) {\n return null;\n }\n\n if ($activity->hasActualStartTime() === false && $activity->hasScheduledStartTime() === false) {\n return null;\n }\n\n if (! $activity->hasProspect()) {\n $this->logger->info('[Salesforce] Skip look up, Activity not linked to Lead, Contact or Account', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n $playbook = $this->getPlaybook($activity->getUser());\n if ($playbook !== null && $playbook->getActivityType() === Playbook::ACTIVITY_TYPE_TASK) {\n $this->logger->info('[Salesforce] Skip auto-sync for task-based playbook', [\n 'activityUuid' => $activity->getUuid(),\n 'playbookId' => $playbook->getId(),\n 'playbookType' => $playbook->getActivityType(),\n ]);\n\n return null;\n }\n\n try {\n $sfEvent = $this->fetchRelatedActivity($activity);\n if (empty($sfEvent)) {\n return null;\n }\n\n [$activityField, $activityType] = $this->resolveActivityTypeFromEvent($activity, $sfEvent);\n\n $this->logger->info('[Salesforce] Found related activity', [\n 'activityId' => $activity->getUuid(),\n 'sfEvent' => $sfEvent['Id'],\n 'activityFieldName' => $activityField,\n 'crmActivityType' => ($activityField !== null && isset($sfEvent[$activityField]))\n ? $sfEvent[$activityField]\n : null,\n 'activityType' => $activityType,\n ]);\n\n $userId = $this->findRelatedActivityUserId($activity, $sfEvent);\n\n if ($activity->getUserId() !== $userId) {\n $this->logger->info('[Salesforce] Updating meeting owner', [\n 'activityId' => $activity->getUuid(),\n 'oldUserId' => $activity->getUserId(),\n 'newUserId' => $userId,\n ]);\n }\n\n $this->updateSfEventDescription($activity, $sfEvent);\n\n $activity->update([\n 'user_id' => $userId,\n 'crm_provider_id' => $sfEvent['Id'],\n 'playbook_category_id' => $activityType->id ?? $activity->getCategory()?->getId(),\n ]);\n\n $this->logger->info('[Salesforce] Activity updated', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return $activity;\n } catch (\\Exception $exception) {\n \\Sentry::captureException($exception);\n\n throw $exception;\n }\n }\n\n /**\n * @param array<string, mixed> $sfEvent\n *\n * @return array{0: string|null, 1: mixed}\n */\n private function resolveActivityTypeFromEvent(Activity $activity, array $sfEvent): array\n {\n $activityField = $this->getActivityFieldName($activity);\n $activityType = null;\n\n if ($activityField !== null && ! empty($sfEvent[$activityField])) {\n $playbook = $this->getPlaybook($activity->getUser());\n $activityType = $this->getPlaybookCategory($playbook, strval($sfEvent[$activityField]));\n }\n\n return [$activityField, $activityType];\n }\n\n /**\n * @param array<string> $sfEvent\n */\n private function findRelatedActivityUserId(Activity $activity, array $sfEvent): int\n {\n $userId = $activity->getUserId();\n\n if (empty($sfEvent['OwnerId']) === false) {\n $profile = $this\n ->config\n ->profiles()\n ->where('crm_provider_id', $sfEvent['OwnerId'])\n ->get()\n ->filter(static function (Profile $profile) use ($activity): bool {\n if (! $activity->isTypeConference()) {\n return ! empty($profile->user) ? $profile->user->isStatusActive() : false;\n }\n\n $participants = $activity->getParticipants();\n\n return ! empty($profile->user)\n ? $profile->user->isStatusActive()\n && $profile->user->hasPermission(PermissionEnum::RECORD_MEETING)\n && $participants->contains('user_id', $profile->user_id)\n : false;\n })\n ->first();\n\n if ($profile) {\n $userId = $profile->user_id;\n }\n }\n\n return $userId;\n }\n\n /**\n * @param array<string, mixed> $sfEvent\n */\n private function updateSfEventDescription(Activity $activity, array $sfEvent): void\n {\n try {\n if (str_contains($sfEvent['Description'], $activity->id_string)) {\n return;\n }\n\n $payload = [\n 'Description' => $sfEvent['Description']\n . PHP_EOL\n . PHP_EOL\n . (new DecorateActivity())->generateDescription($activity),\n ];\n\n $this->logger->info('[Salesforce] Update record', [\n 'activityId' => $activity->getUuid(),\n 'sfEvent' => $sfEvent['Id'],\n 'payload' => $payload,\n ]);\n\n $payload = array_merge(\n $payload,\n $this->payloadBuilder->fetchCustomFieldData($activity, Field::OBJECT_EVENT)\n );\n\n $this->updateRecord('Event', $sfEvent['Id'], $payload);\n } catch (\\Exception) {\n $this->logger->error('[Salesforce] Failed to update record', [\n 'activityUuid' => $activity->getUuid(),\n 'sfEvent' => $sfEvent['Id'],\n ]);\n }\n }\n\n /**\n * Returns the most recently modified Event within time range (if any).\n *\n * @return array|null An Event record from Salesforce.\n */\n private function fetchRelatedEvent(Activity $activity): ?array\n {\n $ownerId = $this->profile?->crm_provider_id;\n if ($ownerId === null) {\n return [];\n }\n\n /** @var ?Carbon $from */\n /** @var ?Carbon $to */\n [$from, $to] = $this->getFromToDates($activity);\n\n try {\n $whoId = null;\n $hasWho = $activity->lead_id || $activity->contact_id;\n if ($hasWho) {\n $whoId = $activity->hasLead()\n ? $activity->getLead()->crm_provider_id\n : $activity->getContact()->crm_provider_id;\n }\n\n if ($hasWho === false && $activity->account_id === null) {\n return null;\n }\n\n $query = $this->buildFetchRelatedEventQuery($activity);\n\n $objects = $this->queryHandler->query($query, [\n 'ownerId' => $ownerId,\n 'whoId' => $whoId,\n 'whatId' => $activity->hasOpportunity() ? $activity->getOpportunity()->crm_provider_id : null,\n 'accountId' => $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null,\n 'from' => $from?->format('Y-m-d\\TH:i:s\\Z'),\n 'to' => $to?->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($objects as $object) {\n return $object;\n }\n } catch (NoResultsException $e) {\n return [];\n }\n\n return [];\n }\n\n private function getFromToDates(Activity $activity): array\n {\n $from = null;\n $to = null;\n\n /** @var ?CalendarEvent $calendarEvent */\n $calendarEvent = $activity->calendarEvent()->first();\n if ($calendarEvent !== null) {\n $from = $calendarEvent->getStartTime();\n $to = $calendarEvent->getEndTime();\n }\n\n // For non-calendar imported activities\n // Also double check if calendar event dates could be null?\n // If null use what we've got so far\n if ($from === null || $to === null) {\n $from = $activity->hasScheduledStartTime()\n ? $activity->getScheduledStartTime()\n : $activity->getActualStartTime();\n $to = $activity->hasScheduledEndTime()\n ? $activity->getScheduledEndTime()->addMinutes(15)\n : $activity->getActualEndTime();\n }\n\n return [$from, $to];\n }\n\n /**\n * Determines the appropriate activity field name for querying Salesforce events.\n *\n * This method follows a hierarchy to determine the field name:\n * 1. Uses the playbook's activity field if it exists and is in the profile's accessible fields\n * 2. Falls back to the default activity field if the profile has no event fields configured\n * 3. Returns null if no suitable field is found\n *\n * @param Activity $activity The activity to determine the field for\n *\n * @return string|null The field name to use in queries, or null if none is available\n */\n private function getActivityFieldName(Activity $activity): ?string\n {\n if ($this->profile === null) {\n $this->logger->warning('[Salesforce] Cannot determine activity field - profile not found', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n $profileEventFields = $this->profile->getFieldsAsArray('event');\n\n if (empty($profileEventFields)) {\n $defaultActivityField = $this->getDefaultActivityField(Field::OBJECT_EVENT);\n $defaultFieldName = $defaultActivityField?->getAttribute('crm_provider_id');\n // Profile not yet synced — fall back to the default activity field.\n // There is a small chance that the profile won't have Default Activity Type field access\n // in which case the query will fail.\n // This is however an edge case and should be reviewed for profile sync issues.\n Sentry::withScope(function (\\Sentry\\State\\Scope $scope) use ($defaultFieldName): void {\n $scope->setContext('details', [\n 'profileId' => $this->profile->id,\n 'defaultField' => $defaultFieldName,\n ]);\n Sentry::captureMessage(\n '[Salesforce] Profile event fields empty, falling back to default activity field.',\n \\Sentry\\Severity::warning()\n );\n });\n\n return $defaultFieldName;\n }\n\n $playbook = $this->getPlaybook($activity->getUser());\n\n if (! is_null($playbook) && ! is_null($playbook->getActivityField())) {\n $playbookFieldName = $playbook->getActivityField()->getAttribute('crm_provider_id');\n\n if (in_array($playbookFieldName, $profileEventFields, true)) {\n return $playbookFieldName;\n }\n\n $this->logger->warning('[Salesforce] Playbook activity field not found in profile fields', [\n 'activityId' => $activity->getUuid(),\n 'playbookField' => $playbookFieldName,\n 'profileId' => $this->profile->id,\n ]);\n }\n\n return null;\n }\n\n private function buildFetchRelatedEventQuery(Activity $activity): string\n {\n $hasWho = $activity->lead_id || $activity->contact_id;\n\n $activityFieldName = $this->getActivityFieldName($activity);\n $fields = array_filter(['Id', 'Description', 'OwnerId', $activityFieldName]);\n\n $ownerCondition = '(OwnerId = :ownerId OR CreatedById = :ownerId)';\n\n $query = '\n SELECT ' . implode(',', $fields) . '\n FROM Event\n WHERE ' . $ownerCondition . '\n AND IsArchived = false\n AND IsAllDayEvent = false\n AND StartDateTime >= :from\n AND EndDateTime <= :to\n AND (';\n\n $operator = '';\n if ($activity->account_id) {\n // This covers events tied to a related contact or opportunity too.\n $query .= 'AccountId = :accountId';\n\n $operator = ' OR ';\n }\n\n if ($hasWho) {\n $query .= $operator . 'WhoId = :whoId';\n\n // If we are also going to check on a specific opportunity, set that up.\n if ($activity->opportunity_id) {\n $query .= ' OR WhatId = :whatId';\n }\n }\n\n $query .= ') ORDER BY LastModifiedDate DESC';\n\n return $query;\n }\n\n public function fetchProspect(array $task): array\n {\n $lead = $account = $opportunity = $contact = $stage = $countryCode = null;\n $externalId = $task['WhoId'] ?? null;\n\n // Lead or Contact\n if ($externalId) {\n try {\n [$lead, $account, $opportunity, $contact, $stage, $countryCode] = $this->parseRecords($externalId);\n } catch (\\InvalidArgumentException $exception) {\n // Invalid object type.\n }\n }\n\n // If we happen to know the opportunity or account from the Task, figure that out.\n if (empty($task['WhatId']) === false) {\n // WhatId could be either Account ID or Opportunity ID.\n // If WhatId is Opportunity ID, get the opportunity and stage from the CRM.\n try {\n [, $account, $opportunity, , $stage, ] = $this->parseRecords($task['WhatId']);\n } catch (\\InvalidArgumentException $exception) {\n // Invalid object type.\n }\n }\n\n return [$lead, $account, $opportunity, $contact, $stage, $countryCode];\n }\n\n /**\n * Save activity transcription summary as note\n */\n public function saveTranscriptionSummaryAsNote(\n ActivityContract $activity,\n string $title,\n string $body,\n ?string $objectId,\n ?NoteObject $noteObject = null,\n ): ?string {\n return $this->saveNote($title, $body, (string) $objectId);\n }\n\n public function getObjectByFilterConditions(string $objectType, array $fields, array $filters): ?array\n {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $query = $queryBuilder->buildObjectSearchQuery($objectType, $fields, $filters);\n\n try {\n $objects = $this->queryHandler->query($query, $filters);\n if ($objects->count() === 1) {\n return $objects->current();\n }\n } catch (\\Exception $e) {\n $this->logger->info('[Salesforce] Failed to execute query', [\n 'query' => $query,\n 'error' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n private function getCustomProfileRules(TeamRepository $teamRepository): array\n {\n $teamSettings = $teamRepository->getTeamSetting($this->team, 'custom_profile_validation');\n\n if ($teamSettings instanceof TeamSettings && $teamSettings->getValueType() === 'array') {\n $customRules = json_decode($teamSettings->getValue(), true);\n if (is_array($customRules)) {\n return $customRules;\n }\n }\n\n return [];\n }\n\n private function customProfileValidation(array $crmUser, array $customRules): bool\n {\n foreach ($customRules as $customRule) {\n if ($crmUser[$customRule['field']] !== $customRule['value']) {\n return false;\n }\n }\n\n return true;\n }\n\n /**\n * When syncing Contact / Lead / Account / Opportunity / Stage crm entities,\n * validate and restore locally trashed objects,\n * before updating them. Objects are identified by CrmProviderId\n */\n private function restoreAnyTrashedEntity(HasMany $targetEntity, string $crmProviderId): void\n {\n $recordExists = $targetEntity->withTrashed()->where(['crm_provider_id' => $crmProviderId])->first();\n if ($recordExists && $recordExists->trashed()) {\n $recordExists->restore();\n }\n }\n\n #[\\Override] public function supportsNotes(): bool\n {\n return true;\n }\n\n private function getOwnerProfile(?string $ownerId): ?Profile\n {\n if ($ownerId === null) {\n return null;\n }\n\n return $this->config->profiles()\n ->where('crm_provider_id', $ownerId)\n ->first();\n }\n}","depth":2,"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Salesforce;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse Illuminate\\Database\\Eloquent\\Relations\\HasMany;\nuse Illuminate\\Events\\Dispatcher;\nuse Illuminate\\Support\\Facades\\Cache;\nuse Illuminate\\Support\\Facades\\Log;\nuse Illuminate\\Support\\Str;\nuse Jiminny\\Component\\Country\\CountriesMap;\nuse Jiminny\\Contracts\\Acl\\PermissionEnum;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\FetchRelatedActivityInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\ImportsBusinessProcessesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\SalesforceBatchSyncInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\SalesforceInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteNoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SearchTaskInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SendSummaryToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SupportsObjectTypeParseInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmProfileRecordTypesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Enums\\CrmObject;\nuse Jiminny\\Events\\Activities\\Crm\\LeadConverted;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpBadRequestException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Exceptions\\NoResultsException;\nuse Jiminny\\Exceptions\\ServiceUnavailableException;\nuse Jiminny\\Jobs\\Crm\\NoteObject;\nuse Jiminny\\Jobs\\Crm\\MatchActivitiesToNewOpportunity;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Calendar\\CalendarEvent;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Contracts\\ActivityContract;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Configuration;\nuse Jiminny\\Models\\Crm\\ContactRole;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Crm\\RecordType;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\TeamSettings;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\ContactRoleRepository;\nuse Jiminny\\Repositories\\Crm\\FieldRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Repositories\\Crm\\RecordTypeFieldValuesRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\Helpers\\ArrayIterator;\nuse Jiminny\\Services\\Crm\\MatchDomainByEmailInterface;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Services\\Crm\\ResolveCompanyNameByEmailTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\Fields\\FieldHelper;\nuse Jiminny\\Services\\Crm\\Salesforce\\Fields\\FieldTypeConverter;\nuse Jiminny\\Services\\Crm\\Salesforce\\Fields\\ValueNormalizer;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\FollowupActivityTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\LogActivityTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\RecordManipulationsTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\SyncFieldsTrait;\nuse Jiminny\\Utils\\CurrencyFormatter;\nuse Jiminny\\Utils\\StringUtil;\nuse Ramsey\\Uuid\\Uuid;\nuse Sentry\\Laravel\\Facade as Sentry;\n\nclass Service extends BaseService implements\n SalesforceInterface,\n SalesforceBatchSyncInterface,\n SyncCrmEntitiesInterface,\n SyncCrmProfileRecordTypesInterface,\n ImportsBusinessProcessesInterface,\n RemoteEntityManipulationInterface,\n FetchRelatedActivityInterface,\n SendSummaryToCrmInterface,\n MatchDomainByEmailInterface,\n SearchTaskInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n SupportsObjectTypeParseInterface,\n RemoteNoteEntityManipulationInterface,\n VerifyTaskExistsInterface\n{\n use ResolveCompanyNameByEmailTrait;\n use SyncFieldsTrait;\n use DeleteObjectsTrait;\n use RecordManipulationsTrait;\n use ServiceTraits\\BatchSyncTrait;\n use FollowupActivityTrait;\n use LogActivityTrait;\n\n /**\n * Note Body Limit for the Old Note-Taking Tool\n *\n * @var int\n */\n private const int CLASSIC_NOTE_MAX_LENGTH = 32000;\n\n /**\n * Note Content Limit for the New Notes\n *\n * @var int\n */\n private const int ENHANCED_NOTE_MAX_LENGTH = 50000000;\n\n private const string INSTALLED_PACKAGE_ID = '033Tw0000007bKbIAI';\n\n private const int CACHE_TTL = 600;\n\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day - 86400\n\n /**\n * @var Client\n */\n protected $client;\n\n protected PayloadBuilder $payloadBuilder;\n protected QueryHandler $queryHandler;\n\n private OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n\n public function __construct(\n Client $client,\n PayloadBuilder $payloadBuilder,\n protected Dispatcher $eventDispatcher,\n private readonly CountriesMap $countriesMap,\n private readonly ProspectPhotoPathService $prospectPhotoPathService,\n ) {\n parent::__construct();\n\n $this->client = $client;\n $this->payloadBuilder = $payloadBuilder;\n $this->queryHandler = app(QueryHandler::class, [\n 'client' => $this->client,\n 'logger' => $this->logger,\n ]);\n $this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n }\n\n public function getDisplayName(): string\n {\n return 'Salesforce';\n }\n\n public function getJobDelay(): int\n {\n return 1;\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n return $user->getSocialAccount(SocialAccount::PROVIDER_SALESFORCE);\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($activity, $crmProviderId) {\n $playbook = $this->getPlaybookFromActivity($activity);\n\n if ($playbook === null) {\n $this->logger->warning('[Salesforce] Cannot verify task - no playbook found', [\n 'activity' => $activity->getId(),\n 'crm_provider_id' => $crmProviderId,\n ]);\n\n return false;\n }\n\n $objectType = $playbook->getActivityType() === Playbook::ACTIVITY_TYPE_EVENT ? 'Event' : 'Task';\n\n try {\n $record = $this->getRecord($objectType, $crmProviderId, ['Id', 'IsDeleted']);\n\n return ! empty($record) && ($record['IsDeleted'] ?? false) === false;\n } catch (HttpNotFoundException|HttpBadRequestException) {\n $this->logger->info('[Salesforce] Activity record not found during verification', [\n 'activity' => $activity->getId(),\n 'object_type' => $objectType,\n 'crm_provider_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n });\n }\n\n public function query(string $queryToRun, array $parameters = []): QueryIterator\n {\n // Due to poorly designed external calls, this method cannot be entirely removed\n return $this->queryHandler->query($queryToRun, $parameters);\n }\n\n /*=========== Organization Information ===============*/\n\n /**\n * Get a list of all the API Versions for the instance.\n *\n * @throws CrmException\n *\n * @return mixed\n *\n */\n public function getApiVersions()\n {\n $url = $this->config->crm_base_url . '/services/data';\n\n $response = $this->client->get($url);\n\n return json_decode($response->getBody(), true);\n }\n\n /**\n * Gets the valid recordTypes for a given Salesforce Object via the describe API.\n */\n private function getRecordTypes(string $crmObject): array\n {\n $url = $this->client->getObjectsUrl() . $crmObject . '/describe';\n\n $response = $this->client->get($url);\n $jsonResponse = json_decode($response->getBody(), true);\n\n $fields = [];\n foreach ($jsonResponse['recordTypeInfos'] as $row) {\n $fields[] = ['recordTypeId' => $row['recordTypeId'], 'default' => $row['defaultRecordTypeMapping']];\n }\n\n return $fields;\n }\n\n /**\n * Convert raw field data into a format compatible with CRM APIs.\n */\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return ValueNormalizer::normalize($fieldType, $fieldValue, $internal);\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n $defaultFields = ($activityType === Playbook::ACTIVITY_TYPE_TASK)\n ? FieldDefinitions::defaultTaskFields()\n : FieldDefinitions::defaultEventFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n // Setup the activity field as the default Type.\n /** @var Field $activityField */\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'Type',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK, Playbook::ACTIVITY_TYPE_EVENT];\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n\n $fieldFilter = ($activityType === Playbook::ACTIVITY_TYPE_TASK)\n ? FieldDefinitions::taskFollowupFieldsFilter()\n : FieldDefinitions::eventFollowupFieldsFilter();\n\n foreach ($fieldFilter as $eachFilter) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $eachFilter);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n /**\n * This one is now called only when ImportActivityTypes is triggered or SyncFieldMetadata executed manually\n * Regular sync now uses SharedSyncFieldsTrait -> syncSingleObjectType\n * Needs to be replaced later on\n */\n public function syncField(Field $field): void\n {\n try {\n if (FieldHelper::isCustomField($field)) {\n $query = '\n SELECT\n Id, Metadata, TableEnumOrId\n FROM\n CustomField\n WHERE\n DeveloperName = :fieldName\n AND\n TableEnumOrId = :fieldType\n AND\n NamespacePrefix = :namespacePrefix';\n\n // We need to constrain the field lookup to the object, in case it's used in multiple places.\n $objectType = \\in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true)\n ? 'activity'\n : $field->object_type;\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'fieldName' => substr($field->crm_provider_id, 0, -\\strlen('__c')),\n 'fieldType' => ucfirst($objectType),\n\n // This is used to ensure we only consider the field within the org, not installed packages.\n 'namespacePrefix' => 'null',\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n // Sync field metadata.\n $metadata = $sfField['Metadata'];\n\n $field->description = mb_strimwidth($metadata['description'] ?? '', 0, 191);\n $field->label = mb_strimwidth($metadata['label'] ?? '', 0, Field::LABEL_MAX_LENGTH);\n $field->type = $this->convertFieldType($metadata['type'], $field->getEntityName());\n $field->is_mandatory = ($metadata['required'] === true);\n $field->length = $metadata['length'];\n $field->default_value = mb_strimwidth(trim($metadata['defaultValue'] ?? '', '\"'), 0, 191);\n $field->save();\n } else {\n $query = '\n SELECT\n Id, DataType, DeveloperName, Label, Length, Description\n FROM\n FieldDefinition\n WHERE\n DurableId = :entityName';\n\n $entityName = $field->getEntityName();\n $sfFields = $this->queryHandler->metadata($query, [\n 'entityName' => $entityName,\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n $convertedType = $this->convertFieldType($sfField['DataType'], $entityName);\n $label = mb_strimwidth($sfField['Label'], 0, Field::LABEL_MAX_LENGTH);\n\n if ($field->isBusinessType()) {\n $label = 'Opportunity Type';\n }\n\n $field->description = mb_strimwidth($sfField['Description'], 0, Field::DESCRIPTION_MAX_LENGTH);\n $field->label = $label;\n $field->type = $convertedType;\n $field->length = $sfField['Length'];\n $field->save();\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n private function convertFieldType(string $from, ?string $entityName = null): string\n {\n $converter = new FieldTypeConverter();\n\n return $converter->convert($from, $entityName);\n }\n\n /**\n * @inheritdoc\n */\n public function importPicklistValues(Field $field): array\n {\n $values = [];\n $fieldValues = [];\n\n try {\n if (FieldHelper::isCustomField($field)) {\n $query = '\n SELECT\n Id, Metadata, TableEnumOrId\n FROM\n CustomField\n WHERE\n DeveloperName = :fieldName\n AND\n TableEnumOrId = :fieldType\n AND\n NamespacePrefix = :namespacePrefix';\n\n // We need to constrain the field lookup to the object, in case it's used in multiple places.\n $objectType = \\in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true) ?\n 'activity' : $field->object_type;\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'fieldName' => substr($field->crm_provider_id, 0, -\\strlen('__c')),\n 'fieldType' => ucfirst($objectType),\n // This is used to ensure we only consider the field within the org, not installed packages.\n 'namespacePrefix' => 'null',\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n $valueSet = $sfField['Metadata']['valueSet'];\n\n if ($valueSet['valueSetName'] === null) {\n // Local picklist values can be obtained easily.\n $picklistValues = $valueSet['valueSetDefinition']['value'];\n } else {\n // But for some fields, we just get the Global Value Picklist pointer so need to do more work.\n $picklistValues = $this->importGlobalValuePicklistValues($valueSet['valueSetName']);\n }\n\n // Import all active values.\n foreach ($picklistValues as $i => $sfFieldValue) {\n // Setup default value.\n if ($sfFieldValue['default']) {\n $field->update(['default_value' => $sfFieldValue['valueName']]);\n }\n\n // This comes through as null if active (lol).\n if ($sfFieldValue['isActive'] !== false) {\n $values[] = [\n 'value' => $sfFieldValue['valueName'],\n 'label' => $sfFieldValue['valueName'],\n 'sequence' => $i,\n 'is_default' => $sfFieldValue['default'],\n ];\n }\n }\n } else {\n $objectFields = $this->getObjectFields($field->object_type);\n $fieldId = $field->crm_provider_id;\n\n // Only work with our field of interest.\n $objectField = array_filter($objectFields, function ($item) use ($fieldId) {\n return $item['name'] === $fieldId;\n });\n\n $objectField = array_shift($objectField);\n if (empty($objectField['picklistValues']) === false) {\n foreach ($objectField['picklistValues'] as $i => $sfFieldValue) {\n // Skip inactive values.\n if ($sfFieldValue['active'] === false) {\n continue;\n }\n\n // Setup default value.\n if ($sfFieldValue['defaultValue']) {\n $field->update(['default_value' => $sfFieldValue['value']]);\n }\n\n $values[] = [\n 'value' => $sfFieldValue['value'],\n 'label' => $sfFieldValue['label'],\n 'sequence' => $i,\n 'is_default' => $sfFieldValue['defaultValue'],\n ];\n }\n }\n }\n\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n $value['value'] = substr($value['value'] ?? '', 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n if (($key = array_search($value['value'], $fieldsToPurge, true)) !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n // Get IDs of the values to be deleted\n $valuesToDelete = $field->values()->whereIn('value', $fieldsToPurge);\n $valuesToDeleteIds = $valuesToDelete->pluck('id');\n if (! $valuesToDeleteIds->isEmpty()) {\n $recordTypeFieldValuesRepository = app(RecordTypeFieldValuesRepository::class);\n $recordTypeFieldValuesRepository->deleteForCrmFieldValueIds($valuesToDeleteIds->toArray());\n\n // Now safely delete from crm_field_values\n $valuesToDelete->delete();\n }\n\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n return $fieldValues;\n }\n\n /**\n * Gets values from Global Value Picklists.\n */\n private function importGlobalValuePicklistValues(string $picklistName): array\n {\n $query = '\n SELECT\n Metadata\n FROM\n GlobalValueSet\n WHERE\n DeveloperName = :picklistName\n LIMIT 1';\n\n try {\n $sfValues = $this->queryHandler->metadata($query, [\n 'picklistName' => $picklistName,\n ]);\n\n // There is always 1 result at this point.\n $sfValue = $sfValues->current();\n\n return $sfValue['Metadata']['customValue'];\n } catch (NoResultsException $noResultsException) {\n // Nothing returned.\n\n return [];\n }\n }\n\n /**\n * @inheritdoc\n */\n public function syncProfileRecordTypes(): void\n {\n $objectTypes = [\n 'lead',\n 'account',\n 'contact',\n 'opportunity',\n 'task',\n 'event',\n ];\n\n foreach ($objectTypes as $objectType) {\n try {\n $crmRecordTypes = $this->getRecordTypes(ucfirst($objectType));\n\n foreach ($crmRecordTypes as $crmRecordType) {\n // If the record type is default and not the Master type, set this.\n if ($crmRecordType['default'] && $crmRecordType['recordTypeId'] !== '012000000000000AAA') {\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $crmRecordType['recordTypeId'])\n ->first();\n\n if ($recordType) {\n $this->profile->{$objectType . '_record_type_id'} = $recordType->id;\n }\n }\n }\n } catch (HttpNotFoundException $exception) {\n Log::error('No access to ' . $objectType . ' object, skipping...');\n\n // XXX: should we log this fact somewhere?\n continue;\n }\n }\n\n if ($this->profile->isDirty()) {\n $this->profile->save();\n }\n }\n\n /**\n * Gets business processes.\n */\n public function importBusinessProcesses(): void\n {\n $query = '\n SELECT\n Id, IsActive, Name, TableEnumOrId\n FROM\n BusinessProcess\n WHERE\n TableEnumOrId IN (\\'Lead\\',\\'Opportunity\\')';\n\n try {\n $sfProcesses = $this->queryHandler->query($query);\n\n // Upsert all processes for the team.\n foreach ($sfProcesses as $sfProcess) {\n /** @var BusinessProcess $businessProcess */\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $sfProcess['Id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => $sfProcess['Name'],\n 'type' => $sfProcess['TableEnumOrId'] === 'Lead' ? 'lead' : 'opportunity',\n 'is_selectable' => $sfProcess['IsActive'],\n ]);\n\n $this->importBusinessProcessStages($businessProcess);\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n /**\n * Gets business process stages.\n */\n private function importBusinessProcessStages(BusinessProcess $businessProcess): void\n {\n $query = '\n SELECT\n Metadata\n FROM\n BusinessProcess\n WHERE\n Id = :processId';\n\n try {\n $stages = [];\n $sfProcessStages = $this->queryHandler->metadata($query, [\n 'processId' => $businessProcess->crm_provider_id,\n ]);\n\n // There is always 1 result at this point.\n $sfProcessStage = $sfProcessStages->current();\n\n // Upsert all processes for the team.\n foreach ($sfProcessStage['Metadata']['values'] as $sfProcessStage) {\n $sanitizedName = urldecode($sfProcessStage['valueName']); // Must decode: \"%2C\" becomes \",\" etc.\n\n $stage = $businessProcess->crm->stages()\n // This MUST match on label because this API doesn't use API Name.\n ->where('label', $sanitizedName)\n ->where('type', $businessProcess->type)\n ->where('is_selectable', 1)\n ->first();\n\n if ($stage) {\n $stages[] = $stage->id;\n }\n }\n\n $businessProcess->stages()->sync($stages);\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n /**\n * Gets record types.\n */\n public function importRecordTypes(): void\n {\n $query = '\n SELECT\n Id, IsActive, Name, BusinessProcessId, SobjectType\n FROM\n RecordType';\n\n try {\n $sfRecordTypes = $this->queryHandler->query($query);\n\n // Upsert all record types for the process.\n foreach ($sfRecordTypes as $sfRecordType) {\n $businessProcess = null;\n if ($sfRecordType['BusinessProcessId']) {\n $businessProcess = $this->config->businessProcesses()\n ->where('crm_provider_id', $sfRecordType['BusinessProcessId'])\n ->first();\n }\n\n /** @var RecordType $recordType */\n $recordType = $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $sfRecordType['Id'],\n ], [\n 'team_id' => $this->team->id,\n 'type' => mb_strtolower($sfRecordType['SobjectType']),\n 'name' => $sfRecordType['Name'],\n 'is_selectable' => $sfRecordType['IsActive'],\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n $this->importRecordTypeFieldValues($recordType);\n }\n } catch (NoResultsException $noResultsException) {\n // Do nothing.\n }\n }\n\n /**\n * Import record type - field value mappings. This only works for standard fields.\n */\n private function importRecordTypeFieldValues(RecordType $recordType): void\n {\n try {\n $query = '\n SELECT\n Metadata\n FROM\n RecordType\n WHERE\n Id = :recordTypeId';\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'recordTypeId' => $recordType->crm_provider_id,\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n // Sync field metadata.\n $picklists = $sfField['Metadata']['picklistValues'];\n\n foreach ($picklists as $picklist) {\n $field = $this->config->fields()->where([\n 'type' => Field::TYPE_PICKLIST,\n 'object_type' => $recordType->type,\n 'crm_provider_id' => $picklist['picklist'],\n ])->first();\n\n if ($field) {\n $fieldValues = [];\n\n foreach ($picklist['values'] as $value) {\n // Must decode: \"%2C\" becomes \",\" etc.\n $fieldValue = $field->values()\n ->where('value', urldecode($value['valueName']))\n ->first();\n\n if ($fieldValue) {\n $fieldValues[] = $fieldValue->id;\n }\n }\n\n $recordType->fieldValues()->sync($fieldValues);\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n /**\n * @inheritdoc\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $params = [];\n $missingStage = null;\n if ($types === null) {\n $types = [Stage::TYPE_LEAD, Stage::TYPE_OPPORTUNITY];\n }\n\n foreach ($types as $type) {\n if ($type === Stage::TYPE_LEAD) {\n $query = '\n SELECT\n Id, ApiName, MasterLabel, SortOrder\n FROM\n LeadStatus';\n } else {\n $query = '\n SELECT\n Id, ApiName, MasterLabel, IsActive, SortOrder, DefaultProbability\n FROM\n OpportunityStage';\n }\n\n if ($missingStageName) {\n $escapedStageName = ValueNormalizer::replaceQueryWithStringLiterals($missingStageName);\n\n $query .= ' WHERE ApiName = :stageName';\n\n $params = [\n 'stageName' => $escapedStageName,\n ];\n }\n\n try {\n $sfStages = $this->queryHandler->query($query, $params);\n } catch (NoResultsException $exception) {\n $sfStages = [];\n }\n\n $missingStage = null;\n\n // Upsert all stages for the team.\n foreach ($sfStages as $sfStage) {\n $selectable = true;\n if (array_key_exists('IsActive', $sfStage)) {\n $selectable = $sfStage['IsActive'];\n }\n\n $this->restoreAnyTrashedEntity($this->config->stages(), $sfStage['Id']);\n\n $stage = $this->config->stages()->updateOrCreate([\n 'crm_provider_id' => $sfStage['Id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($sfStage['ApiName'], 0, 50),\n 'label' => mb_strimwidth($sfStage['MasterLabel'], 0, 191),\n 'type' => $type,\n 'sequence' => $sfStage['SortOrder'] ?? 0,\n 'is_selectable' => $selectable,\n 'probability' => $sfStage['DefaultProbability'] ?? null,\n ]);\n\n if ($missingStageName && $missingStageName === $sfStage['ApiName']) {\n $missingStage = $stage;\n }\n }\n\n if ($missingStageName && $missingStage === null) {\n // If they requested a stage that still doesn't exist, it must be inactive so lazy create it.\n $missingStage = $this->config->stages()->create([\n 'crm_provider_id' => Uuid::uuid4(),\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($missingStageName, 0, 50),\n 'label' => mb_strimwidth($missingStageName, 0, 191),\n 'type' => $type,\n 'sequence' => 0,\n 'is_selectable' => 0,\n ]);\n }\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int\n {\n $syncCount = 0;\n $fields = $this->getAllFieldsAsArray('lead');\n if (\\in_array('Id', $fields, true) === false) {\n return $syncCount;\n }\n\n $query = '\n SELECT ' . rtrim(implode(',', $fields), ',') . '\n FROM Lead\n WHERE LastModifiedDate > :since\n ORDER BY LastModifiedDate ASC';\n\n try {\n $sfLeads = $this->queryHandler->query($query, [\n 'since' => $since->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($sfLeads as $sfLead) {\n // Only sync if previously imported.\n if ($this->hasLead($sfLead['Id'])) {\n $this->importLead($sfLead);\n $syncCount++;\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::LEAD);\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLead(string $crmId): ?Lead\n {\n $fields = $this->getAllFieldsAsArray('lead');\n\n $sfLead = $this->getRecord('Lead', $crmId, $fields);\n\n return $this->importLead($sfLead);\n }\n\n private function importLead($crmData): ?Lead\n {\n /** @var ?Stage $stage */\n $stage = null;\n if (isset($crmData['Status'])) {\n // Get the current stage.\n $stage = $this->config\n ->stages()\n ->where('name', $crmData['Status'])\n ->where('type', Stage::TYPE_LEAD)\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_LEAD], $crmData['Status']);\n }\n }\n\n // If we have no way of importing this, just return null :(\n if ($stage === null) {\n return null;\n }\n\n $countryCode = $crmData['CountryCode'] ?? null;\n\n // Salesforce allows custom \"countries\" to be created. Disregard these.\n if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {\n $countryCode = null;\n }\n\n // If we have no country code, try to parse it from the country name.\n if ($countryCode === null && empty($crmData['Country']) !== false) {\n $countryCode = $this->convertCountryNameToCode($crmData['Country']);\n }\n\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($crmData['Phone'] ?? '', 0, 25);\n $parsedNumber = parsePhoneNumber($countryCode, $number);\n\n $mobilePhone = null;\n if (empty($crmData['MobilePhone']) === false) {\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($crmData['MobilePhone'], 0, 25);\n $mobilePhone = phone_e164($countryCode, $number);\n }\n\n $convertedDate = null;\n $convertedAccount = null;\n $convertedOpportunity = null;\n $convertedContact = null;\n\n if ($crmData['IsConverted'] == 'true') {\n $convertedDate = $crmData['ConvertedDate'];\n\n if (empty($crmData['ConvertedAccountId']) === false) {\n $convertedAccount = $this->config\n ->accounts()\n ->where('crm_provider_id', $crmData['ConvertedAccountId'])\n ->first();\n\n if ($convertedAccount === null) {\n try {\n $convertedAccount = $this->syncAccount($crmData['ConvertedAccountId']);\n } catch (HttpNotFoundException $exception) {\n // Probably the user has no permissions to access the converted data.\n }\n }\n }\n\n if (empty($crmData['ConvertedOpportunityId']) === false) {\n $convertedOpportunity = $this->config\n ->opportunities()\n ->where('crm_provider_id', $crmData['ConvertedOpportunityId'])\n ->first();\n\n if ($convertedOpportunity === null) {\n try {\n $convertedOpportunity = $this->syncOpportunity($crmData['ConvertedOpportunityId']);\n } catch (HttpNotFoundException $exception) {\n // Probably the user has no permissions to access the converted data.\n }\n }\n }\n\n if (empty($crmData['ConvertedContactId']) === false) {\n $convertedContact = $this->team\n ->crm\n ->contacts()\n ->where('crm_provider_id', $crmData['ConvertedContactId'])\n ->first();\n\n if ($convertedContact === null) {\n try {\n $convertedContact = $this->syncContact($crmData['ConvertedContactId']);\n } catch (HttpNotFoundException $exception) {\n // Probably the user has no permissions to access the converted data.\n }\n }\n }\n }\n\n if (empty($crmData['Company'])) {\n $company = 'Unknown';\n } else {\n $company = mb_strimwidth($crmData['Company'], 0, 191);\n }\n\n $domain = null;\n if (empty($crmData['Website']) === false) {\n $domain = mb_strimwidth($crmData['Website'], 0, 191);\n $domain = StringUtil::resolveDomain($domain);\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $data = [\n 'team_id' => $this->team->id,\n 'user_id' => $profile?->user_id,\n 'owner_id' => $crmData['OwnerId'] ?? '',\n 'company' => $company,\n 'domain' => $domain,\n 'name' => $crmData['Name'] ? mb_strimwidth($crmData['Name'], 0, 191) : '',\n 'title' => $crmData['Title'] ? mb_strimwidth($crmData['Title'], 0, 128) : null,\n 'email' => $crmData['Email'] ? mb_strimwidth($crmData['Email'], 0, 80) : null,\n 'phone' => $parsedNumber['phone'],\n 'ext' => $parsedNumber['ext'] ?? null,\n 'mobile_phone' => $mobilePhone,\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['Id'],\n modelType: Lead::class,\n fileName: $crmData['Id'],\n avatarText: $crmData['Name']\n ),\n 'stage_id' => $stage->id,\n 'record_type_id' => null,\n 'converted_at' => $convertedDate,\n 'converted_account_id' => $convertedAccount->id ?? null,\n 'converted_opportunity_id' => $convertedOpportunity->id ?? null,\n 'converted_contact_id' => $convertedContact->id ?? null,\n 'country_code' => $countryCode,\n 'remotely_created_at' => $createdDate,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->leads(), $crmData['Id']);\n\n /** @var Lead $lead */\n $lead = $this->config->leads()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n if ($lead->wasChanged('converted_at') && $lead->getConvertedAt() !== null) {\n $this->eventDispatcher->dispatch(new LeadConverted($lead));\n }\n\n $this->handleObjectDeletion($lead, $crmData);\n\n return $lead;\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccounts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n $fields = $this->getAllFieldsAsArray('account');\n\n if (\\in_array('Id', $fields, true) === false) {\n return $syncCount;\n }\n\n $query = '\n SELECT ' . rtrim(implode(',', $fields), ',') . '\n FROM Account\n WHERE LastModifiedDate > :since\n ORDER BY LastModifiedDate ASC';\n\n try {\n $sfAccounts = $this->queryHandler->query($query, [\n 'since' => $since->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($sfAccounts as $sfAccount) {\n // Only sync if previously imported.\n if ($this->hasAccount($sfAccount['Id'])) {\n $this->importAccount($sfAccount);\n $syncCount++;\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::ACCOUNT);\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccount(string $crmId): ?Account\n {\n $fields = $this->getAllFieldsAsArray('account');\n if (! in_array('Id', $fields, true)) {\n $this->logger->info('[Salesforce] Sync account cancelled. Fields are not available.', [\n 'crmId' => $crmId,\n 'userId' => $this->profile->getUserId(),\n ]);\n\n return null;\n }\n\n $sfAccount = $this->getRecord('Account', $crmId, $fields);\n\n return $this->importAccount($sfAccount);\n }\n\n private function importAccount($crmData): Account\n {\n $countryCode = $crmData['BillingCountryCode'] ?? $crmData['ShippingCountryCode'] ?? null;\n\n // Salesforce allows custom \"countries\" to be created. Disregard these.\n if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {\n $countryCode = null;\n }\n\n // If we have no country code, try to parse it from the country names.\n if ($countryCode === null && empty($crmData['BillingCountry']) === false) {\n $countryCode = $this->convertCountryNameToCode($crmData['BillingCountry']);\n }\n\n if ($countryCode === null && empty($crmData['ShippingCountry']) === false) {\n $countryCode = $this->convertCountryNameToCode($crmData['ShippingCountry']);\n }\n\n if (empty($crmData['Phone']) === false) {\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($crmData['Phone'], 0, 25);\n $parsedNumber = parsePhoneNumber($countryCode, $number);\n } else {\n $parsedNumber = [];\n }\n\n $industry = null;\n if (empty($crmData['Industry']) === false) {\n $industry = mb_strimwidth($crmData['Industry'], 0, 40);\n }\n\n $domain = null;\n if (empty($crmData['Website']) === false) {\n $domain = mb_strimwidth($crmData['Website'], 0, 191);\n $domain = StringUtil::resolveDomain($domain);\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $data = [\n 'team_id' => $this->team->id,\n 'user_id' => $profile?->user_id,\n 'owner_id' => $crmData['OwnerId'],\n 'name' => mb_strimwidth($crmData['Name'], 0, 191),\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['Id'],\n modelType: Account::class,\n fileName: $crmData['Id'],\n avatarText: $crmData['Name']\n ),\n 'industry' => $industry,\n 'domain' => $domain,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'ext' => $parsedNumber['ext'] ?? null,\n 'country_code' => $countryCode,\n 'remotely_created_at' => $createdDate,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->accounts(), $crmData['Id']);\n\n /** @var Account $account */\n $account = $this->config->accounts()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n $this->handleObjectDeletion($account, $crmData);\n\n return $account;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOpportunities(array $parameters, ?string $strategy = null): int\n {\n $strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);\n\n $syncCount = 0;\n $logParams = $parameters;\n $parameters['profile'] = $this->profile;\n $logParams['user'] = $this->profile->getUserId();\n\n if (count($strategies) > 1) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Multiple sync strategies used', [\n 'teamId' => $this->team->getUuid(),\n 'params' => $logParams,\n 'strategies_count' => count($strategies),\n ]);\n }\n\n foreach ($strategies as $syncStrategy) {\n $name = $syncStrategy->getStrategyName();\n\n try {\n $sfOpportunities = $syncStrategy->fetchOpportunities($parameters);\n $totalRecords = $sfOpportunities->count();\n\n foreach ($sfOpportunities as $sfOpportunity) {\n $this->importOpportunity($sfOpportunity);\n $syncCount++;\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n $this->logger->warning('[' . $this->getDisplayName() . '] No opportunities found', [\n 'teamId' => $this->team->getUuid(),\n 'name' => $name,\n 'params' => $logParams,\n 'reason' => $noResultsException->getMessage(),\n ]);\n } catch (CrmException $crmException) {\n // Nothing to sync.\n $this->logger->warning('[' . $this->getDisplayName() . '] Opportunity sync failed', [\n 'teamId' => $this->team->getUuid(),\n 'name' => $name,\n 'params' => $logParams,\n 'reason' => $crmException->getMessage(),\n ]);\n }\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::OPPORTUNITY, ['params' => $logParams]);\n\n // debug to see how if count of opportunities reaches 1000\n if ($syncCount >= 1000) {\n $this->logger->info(\n '[' . $this->getDisplayName() . '] Sync Opportunities - count warning',\n [\n 'team_id' => $this->team->getId(),\n 'params' => $logParams,\n 'count' => $syncCount,\n 'strategies_count' => count($strategies),\n 'total_records' => $totalRecords ?? null,\n ]\n );\n }\n\n return $syncCount;\n }\n\n\n /**\n * @inheritdoc\n */\n public function syncOpportunity(string $crmId): ?Opportunity\n {\n $strategy = $this->opportunitySyncStrategyResolver->resolve(\n $this->config,\n OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY\n );\n\n $parameters = [\n 'profile' => $this->profile,\n 'crm_id' => $crmId,\n ];\n\n try {\n $sfOpportunity = $strategy->fetchOpportunities($parameters);\n } catch (HttpNotFoundException $e) {\n $this->logger->info('[' . $this->getDisplayName() . '] Opportunity not found', [\n 'teamId' => $this->team->id_string,\n 'crmId' => $crmId,\n ]);\n\n return null;\n } catch (CrmException $crmException) {\n $this->logger->info('[' . $this->getDisplayName() . '] Opportunity sync failed', [\n 'teamId' => $this->team->id_string,\n 'crmId' => $crmId,\n 'exception' => $crmException->getMessage(),\n ]);\n\n return null;\n }\n\n if ($sfOpportunity instanceof ArrayIterator) {\n return $this->importOpportunity($sfOpportunity->getItems());\n }\n\n return $this->importOpportunity($sfOpportunity);\n }\n\n /**\n * @throws HttpNotFoundException\n */\n private function importOpportunity($crmData): ?Opportunity\n {\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $account = null;\n if (empty($crmData['AccountId']) === false) {\n /** @var ?Account $account */\n $account = $this->config->accounts()\n ->where('crm_provider_id', (string) $crmData['AccountId'])\n ->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmData['AccountId']);\n }\n }\n\n $userId = $profile?->getUserId() ?? $account?->getUserId();\n if ($userId === null) {\n $this->logger->error('[Salesforce] | Skip import, no user_id found', [\n 'id' => $crmData['Id'],\n ]);\n\n return null;\n }\n\n /** @var ?Stage $stage */\n $stage = null;\n if (isset($crmData['StageName'])) {\n $stage = $this->config\n ->stages()\n ->where('name', $crmData['StageName'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->orderBy('is_selectable', 'DESC')\n ->orderBy('id')\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $crmData['StageName']);\n }\n }\n\n $recordType = null;\n if (empty($crmData['RecordTypeId']) === false) {\n /** @var ?RecordType $recordType */\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $crmData['RecordTypeId'])\n ->first();\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $closeDate = null;\n if (empty($crmData['CloseDate']) === false) {\n $closeDate = Carbon::parse($crmData['CloseDate'])->format('Y-m-d');\n }\n\n $valueFieldName = 'Amount';\n if ($this->config->opportunity_value_field_id) {\n $valueFieldName = $this->config->opportunityValueField->crm_provider_id;\n }\n\n $data = [\n 'team_id' => $this->team->id,\n 'account_id' => $account->id ?? null,\n 'user_id' => $userId,\n 'owner_id' => $crmData['OwnerId'] ?? null,\n 'name' => mb_strimwidth($crmData['Name'] ?? '', 0, 128),\n 'value' => $crmData[$valueFieldName],\n 'currency_code' => CurrencyFormatter::formatCode($crmData['CurrencyIsoCode'] ?? null),\n 'close_date' => $closeDate,\n 'is_closed' => $crmData['IsClosed'],\n 'is_won' => $crmData['IsWon'],\n 'stage_id' => $stage?->id ?? null,\n 'record_type_id' => $recordType->id ?? null,\n 'remotely_created_at' => $createdDate,\n 'probability' => $crmData['Probability'] ?? null,\n 'forecast_category' => $crmData['ForecastCategoryName'] ?? null,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->opportunities(), $crmData['Id']);\n\n // Do not allow locked DB tables & other errors\n // to interrupt the process of reverting the trashed opportunities\n try {\n /** @var Opportunity $opportunity */\n $opportunity = $this->config->opportunities()\n ->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n // import external fields into crm_field_data if present\n $crmFields = $this->getOpportunitySyncableFields();\n\n $this->importOpportunityCrmFieldData($crmData, $crmFields, $opportunity->id);\n\n $this->handleObjectDeletion($opportunity, $crmData);\n\n if ($opportunity->wasRecentlyCreated) {\n MatchActivitiesToNewOpportunity::dispatch($opportunity->getId());\n }\n\n return $opportunity;\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n $this->logger->error('[Salesforce] importOpportunity failure.', [\n 'crm_provider_id' => $crmData['Id'],\n 'team_id' => $this->team->id,\n 'exception' => $exception->getMessage(),\n ]);\n\n $this->handleEntityDeletionByProviderId($this->config->opportunities(), $crmData);\n }\n\n return null;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContacts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n $fields = $this->getAllFieldsAsArray('contact');\n if (\\in_array('Id', $fields, true) === false) {\n return $syncCount;\n }\n\n $query = '\n SELECT ' . rtrim(implode(',', $fields), ',') . '\n FROM Contact\n WHERE LastModifiedDate > :since\n ORDER BY LastModifiedDate ASC';\n\n try {\n $sfContacts = $this->queryHandler->query($query, [\n 'since' => $since->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($sfContacts as $sfContact) {\n // Only sync if previously imported.\n if ($this->hasContact($sfContact['Id'])) {\n $this->importContact($sfContact);\n $syncCount++;\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::CONTACT);\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContact(string $crmId): ?Contact\n {\n $fields = $this->getAllFieldsAsArray('contact');\n if (! in_array('Id', $fields, true)) {\n $this->logger->info('[Salesforce] Sync contact cancelled. Fields are not available.', [\n 'crmId' => $crmId,\n 'userId' => $this->profile->getUserId(),\n ]);\n\n return null;\n }\n\n $sfContact = $this->getRecord('Contact', $crmId, $fields);\n\n return $this->importContact($sfContact);\n }\n\n private function importContact($crmData): Contact\n {\n $account = null;\n // Contacts may not have accounts...\n if (isset($crmData['AccountId'])) {\n $account = $this->config->accounts()\n ->where('crm_provider_id', (string) $crmData['AccountId'])\n ->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmData['AccountId']);\n }\n }\n\n $countryCode = $crmData['MailingCountryCode'] ?? null;\n\n // Salesforce allows custom \"countries\" to be created. Disregard these.\n if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {\n $countryCode = null;\n }\n\n // If we have no country code, try to parse it from the country name.\n if ($countryCode === null && empty($crmData['MailingCountry']) === false) {\n $countryCode = $this->convertCountryNameToCode($crmData['MailingCountry']);\n\n if ($countryCode === null && $account) {\n $countryCode = $account->country_code;\n }\n }\n\n $ext = null;\n $parsedNumber = [];\n if (empty($crmData['Phone']) === false) {\n $number = Str::limit($crmData['Phone'], 25, '');\n $parsedNumber = parsePhoneNumber($countryCode, $number);\n\n if (empty($parsedNumber['ext']) === false) {\n $ext = Str::limit($parsedNumber['ext'], 10, '');\n }\n }\n\n $mobileNumber = null;\n if (empty($crmData['MobilePhone']) === false) {\n $mobileNumber = Str::limit(phone_e164($countryCode, $crmData['MobilePhone']), 25, '');\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $data = [\n 'team_id' => $this->team->id,\n 'account_id' => $account->id ?? null,\n 'user_id' => $profile?->user_id,\n 'owner_id' => $crmData['OwnerId'] ?? null,\n 'name' => ($crmData['Name'] ?? null) !== null ? mb_strimwidth($crmData['Name'], 0, 100) : '',\n 'title' => ($crmData['Title'] ?? null) !== null ? mb_strimwidth($crmData['Title'], 0, 128) : null,\n 'email' => ($crmData['Email'] ?? null) !== null ? mb_strimwidth($crmData['Email'], 0, 191) : null,\n 'country_code' => $countryCode,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'ext' => $ext,\n 'mobile_phone' => $mobileNumber,\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['Id'],\n modelType: Contact::class,\n fileName: $crmData['Id'],\n avatarText: $crmData['Name']\n ),\n 'remotely_created_at' => $createdDate,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->contacts(), $crmData['Id']);\n\n /** @var Contact $contact */\n $contact = $this->config->contacts()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n $this->handleObjectDeletion($contact, $crmData);\n\n return $contact;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n $fields = [\n 'InstanceName',\n 'OrganizationType',\n 'IsSandbox',\n ];\n\n $orgValues = $this->getRecord('Organization', $this->config->crm_provider_id, $fields);\n\n $edition = null;\n switch ($orgValues['OrganizationType']) {\n case 'Developer Edition':\n $edition = Configuration::EDITION_DEVELOPER;\n\n break;\n\n case 'Professional Edition':\n $edition = Configuration::EDITION_PROFESSIONAL;\n\n break;\n\n case 'Enterprise Edition':\n $edition = Configuration::EDITION_ENTERPRISE;\n\n break;\n }\n\n $this->config->edition = $edition;\n $this->config->instance = $orgValues['InstanceName'];\n\n // XXX: How can this state be possible?\n if ($this->config->version === null) {\n $this->config->version = Client::MIN_API_VERSION;\n }\n\n $installedVersion = $this->getInstalledAppVersion();\n if ($installedVersion !== null) {\n $installedVersion = (string) $this->getInstalledAppVersion();\n }\n\n $this->config->installed_app_version = $installedVersion;\n\n $this->config->save();\n }\n\n public function getInstalledAppVersion(): ?string\n {\n try {\n $query = '\n SELECT\n SubscriberPackageVersion.MajorVersion,\n SubscriberPackageVersion.MinorVersion,\n SubscriberPackageVersion.PatchVersion,\n SubscriberPackageVersion.BuildNumber\n FROM\n InstalledSubscriberPackage\n WHERE\n SubscriberPackageId = :packageId\n ';\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'packageId' => self::INSTALLED_PACKAGE_ID,\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n // Grab version number.\n $version = $sfField['SubscriberPackageVersion']['MajorVersion'] .\n $sfField['SubscriberPackageVersion']['MinorVersion'] .\n $sfField['SubscriberPackageVersion']['PatchVersion'] .\n $sfField['SubscriberPackageVersion']['BuildNumber'];\n } catch (\\Exception) {\n $version = null;\n }\n\n return $version;\n }\n\n /**\n * Store transcripts as note.\n *\n * @throws \\Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // For SF we also check if Log Notes is enabled.\n if ($this->profile->log_notes === Profile::LOG_NOTE_NONE) {\n return;\n }\n\n if ($activity->opportunity_id && $activity->prospect === null) {\n return;\n }\n\n try {\n $transcriptionData = $this->generateTranscription($activity);\n\n $noteMaxLength = $this->profile->log_notes === Profile::LOG_NOTE_ENHANCED\n ? self::ENHANCED_NOTE_MAX_LENGTH\n : self::CLASSIC_NOTE_MAX_LENGTH;\n\n $title = 'Transcript for ';\n $title .= $activity->title ?? $activity->activity_title;\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $body = mb_strimwidth($transcriptionData, 0, $noteMaxLength);\n\n if ($activity->opportunity_id) {\n $objectId = $activity->opportunity->crm_provider_id;\n } else {\n $objectId = $activity->prospect->crm_provider_id;\n }\n\n $noteId = $this->saveNote($title, $body, $objectId);\n\n // Store crm logged id in transcription.\n $transcription = $activity->getTranscription();\n $transcription->crm_activity_id = $noteId;\n $transcription->save();\n } catch (\\Exception $e) {\n \\Sentry::captureException($e);\n }\n }\n\n public function saveNote(string $title, string $body, string $objectId, ?NoteObject $noteObject = null): ?string\n {\n $noteId = null;\n\n try {\n if ($this->profile->log_notes === Profile::LOG_NOTE_ENHANCED) {\n $noteId = $this->buildEnhancedNote($title, $body, $objectId);\n } else {\n $noteId = $this->buildClassicNote($title, $body, $objectId);\n }\n } catch (HttpNotFoundException $exception) {\n // The profile not having access to create Enhanced Notes. Set their preference to Classic.\n if ($this->profile->log_notes === Profile::LOG_NOTE_ENHANCED) {\n $this->profile->update([\n 'log_notes' => Profile::LOG_NOTE_CLASSIC,\n ]);\n }\n }\n\n return $noteId;\n }\n\n /**\n * This is using the \"Enhanced\" Notes feature, NOT the \"Notes & Attachments\" feature being deprecated.\n *\n * @url https://salesforce.stackexchange.com/questions/104408/how-can-i-create-an-account-note-or-contact-note-via-api-that-is-visible-in-sale\n */\n private function buildEnhancedNote(string $title, string $body, string $objectId): string\n {\n // Decode stored entities, escape HTML (without quoting), then convert line breaks for Salesforce formatting\n $decodedBody = html_entity_decode($body, ENT_QUOTES | ENT_HTML5);\n $sanitizedBody = htmlspecialchars($decodedBody, ENT_NOQUOTES, 'UTF-8', false);\n $content = nl2br($sanitizedBody, false);\n $note = [\n 'OwnerId' => $this->profile->crm_provider_id,\n 'Title' => $title,\n 'Content' => base64_encode($content),\n ];\n\n $noteId = $this->createRecord('ContentNote', $note);\n\n $link = [\n 'ContentDocumentId' => $noteId,\n 'LinkedEntityId' => $objectId,\n 'ShareType' => 'I',\n ];\n\n $this->createRecord('ContentDocumentLink', $link);\n\n return $noteId;\n }\n\n private function buildClassicNote(string $title, string $body, string $objectId): string\n {\n if (in_array($this->parseObjectType($objectId), [Field::OBJECT_TASK, Field::OBJECT_EVENT])) {\n $this->logger->info('[Salesforce] Summary not sent', [\n 'profile_id' => $this->profile->id,\n 'objectId' => $objectId,\n 'reason' => 'Classical Note does not support Task/Event relation',\n ]);\n\n return '';\n }\n\n $titleTrimmed = null;\n\n if (mb_strlen($title) > 80) {\n $titleTrimmed = substr($title, 0, 77) . '...';\n }\n $payload = [\n 'OwnerId' => $this->profile->crm_provider_id,\n 'IsPrivate' => false,\n 'Title' => $titleTrimmed ?? $title,\n 'Body' => $titleTrimmed ? $title . PHP_EOL . $body : $body,\n 'ParentId' => $objectId,\n ];\n\n return $this->createRecord('Note', $payload);\n }\n\n /**\n * @inheritdoc\n */\n public function find(string $name, array $scopes): array\n {\n if ($this->profile === null) {\n return [];\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $limitValues = ['limit' => $this->limit, 'offset' => $this->offset];\n $sosl = $queryBuilder->buildFindQuery($name, $scopes, $limitValues);\n\n $this->logger->info('[Salesforce] Find prospects', [\n 'profile_id' => $this->profile->id,\n 'sosl_query' => $sosl,\n 'search_string' => $name,\n 'scopes' => $scopes,\n ]);\n\n $data = Cache::remember($this->profile->id . $sosl, self::CACHE_TTL, function () use ($sosl) {\n $data = [];\n\n try {\n // Hit remote API.\n $objects = $this->queryHandler->search($sosl);\n\n // Build mapped list.\n foreach ($objects as $object) {\n $type = strtolower($object['attributes']['type']);\n\n $record = [\n 'crmId' => $object['Id'],\n 'name' => $object['Name'],\n 'prospectType' => $type,\n 'phoneNumbers' => [],\n 'crmUrl' => $this->generateProviderUrl($object['Id'], $type),\n ];\n\n switch ($type) {\n case 'lead':\n if (empty($object['Company']) === false) {\n $record['organization'] = $object['Company'];\n }\n\n if (empty($object['Title']) === false) {\n $record['title'] = $object['Title'];\n }\n\n $stage = $this->config->stages()\n ->where('type', Stage::TYPE_LEAD)\n ->where('name', $object['Status'])\n ->first();\n\n // Lazy create the stage.\n if ($stage === null) {\n $stage = $this->importStages([Stage::TYPE_LEAD], $object['Status']);\n }\n\n if ($stage) {\n $record += [\n 'stage' => [\n 'id' => $stage->id_string,\n 'name' => $stage->name,\n ],\n ];\n }\n\n if (empty($object['RecordTypeId']) === false) {\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $object['RecordTypeId'])\n ->first();\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n }\n\n break;\n\n case 'account':\n if (empty($object['Industry']) === false) {\n $record['industry'] = $object['Industry'];\n $record['detailsLine'] = $object['Industry'];\n }\n if (! empty($object['PersonEmail'])) {\n $record['detailsLine'] = $object['PersonEmail'];\n }\n\n break;\n\n case 'contact':\n // For contacts, we should try and fetch their account name too.\n if ($object['AccountId']) {\n // Cheaper to get this locally.\n $account = $this->config->accounts()\n ->where('crm_provider_id', $object['AccountId'])\n ->first(['name']);\n\n if ($account) {\n $record['organization'] = $account->name;\n }\n }\n\n if (! empty($object['IsPersonAccount']) && $object['Email']) {\n $record['detailsLine'] = $object['Email'];\n } else {\n if (empty($object['Title']) === false) {\n $record['title'] = $object['Title'];\n }\n }\n\n break;\n }\n\n // Add phone numbers to record.\n if (empty($object['Phone']) === false && $object['Phone']) {\n $record['phoneNumbers'][] = [\n 'number' => $object['Phone'],\n 'nationalFormat' => phone_national($this->profile->user->country_code, $object['Phone']),\n 'type' => 'phone',\n ];\n }\n\n if (empty($object['MobilePhone']) === false && $object['MobilePhone']) {\n $record['phoneNumbers'][] = [\n 'number' => $object['MobilePhone'],\n 'nationalFormat' => phone_national(\n $this->profile->user->country_code,\n $object['MobilePhone']\n ),\n 'type' => 'mobile',\n ];\n }\n\n $data[] = $record;\n }\n } catch (NoResultsException $e) {\n $data = [];\n }\n\n return $data;\n });\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n\n if ($crmAccountId === null) {\n return $data;\n }\n\n if ($userId) {\n $profileRepository = app(ProfileRepository::class);\n $profile = $profileRepository->findProfileByUserId($this->config, $userId);\n\n $ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;\n }\n\n try {\n // Perhaps their profile has no opportunity permissions.\n if ($this->profile === null || $this->profile->opportunity_fields === null) {\n return $data;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $query = $queryBuilder->buildFindOpportunitiesQuery();\n\n $objects = $this->queryHandler->query($query, ['accountId' => $crmAccountId]);\n\n foreach ($objects as $object) {\n $record = [\n 'crmId' => $object['Id'],\n 'name' => $object['Name'],\n 'won' => $object['IsWon'],\n 'closed' => $object['IsClosed'],\n ];\n\n $valueFieldName = 'Amount';\n if ($this->config->opportunity_value_field_id) {\n $valueFieldName = $this->config->opportunityValueField->crm_provider_id;\n }\n\n if (empty($object[$valueFieldName]) === false) {\n $currency = $object['CurrencyIsoCode'] ?? $this->config->default_currency;\n $value = formatCurrency($object[$valueFieldName], $currency);\n\n $record += [\n 'value' => $value,\n ];\n }\n\n $stage = $this->config->stages()\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->where('name', $object['StageName'])\n ->first();\n\n // Lazy create the stage.\n if ($stage === null) {\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $object['StageName']);\n }\n\n $record += [\n 'stage' => [\n 'id' => $stage->id_string,\n 'name' => $stage->name,\n ],\n ];\n\n if (empty($object['RecordTypeId']) === false) {\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $object['RecordTypeId'])\n ->first();\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n }\n\n if ($ownerId && isset($object['OwnerId']) && $object['OwnerId'] === $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n } catch (NoResultsException $e) {\n return $data;\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n public function getContactRolesFromCrm(?Carbon $since = null): array\n {\n $roles = [];\n\n if ($this->profile === null) {\n return $roles;\n }\n\n $queryBuilder = app(QueryBuilder::class, ['profile' => $this->profile]);\n\n $query = $queryBuilder->buildGetContactRolesQuery($since);\n\n try {\n $objects = $this->queryHandler->query($query);\n\n foreach ($objects as $object) {\n $roles[] = [\n 'id' => $object['Id'],\n 'contactId' => $object['ContactId'],\n 'opportunityId' => $object['OpportunityId'],\n 'ownerId' => $object['Opportunity']['OwnerId'] ?? null,\n 'isPrimary' => $object['IsPrimary'],\n 'role' => $object['Role'],\n ];\n }\n } catch (NoResultsException) {\n // Just return an empty array.\n $this->logger->info('[Salesforce] No contact roles found', [\n 'since' => $since?->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n }\n\n return $roles;\n }\n\n public function syncContactRoles(Carbon $since): int\n {\n $contactRoleRepository = app(ContactRoleRepository::class);\n\n $crmContactRoles = $this->getContactRolesFromCrm(since: $since);\n $syncCount = 0;\n $contactRoles = [];\n\n foreach ($crmContactRoles as $crmContactRole) {\n $contactRoles[] = $this->importContactRole($crmContactRole);\n $syncCount++;\n }\n\n $contactRoleRepository->saveContactRoles($contactRoles);\n\n $this->syncRemotelyDeletedContactRoles();\n\n return $syncCount;\n }\n\n private function importContactRole(array $contactRole): array\n {\n $contact = $this->config->contacts()\n ->where('crm_provider_id', $contactRole['contactId'])\n ->first();\n\n if ($contact === null) {\n $contact = $this->syncContact($contactRole['contactId']);\n }\n\n $opportunity = $this->config->opportunities()\n ->where('crm_provider_id', $contactRole['opportunityId'])\n ->first();\n\n if ($opportunity === null) {\n $opportunity = $this->syncOpportunity($contactRole['opportunityId']);\n }\n\n $role = null;\n if (! empty($contactRole['role'])) {\n $role = mb_strimwidth($contactRole['role'], 0, 191);\n }\n\n return [\n 'crm_configuration_id' => $this->config->getId(),\n 'contact_id' => $contact->getId(),\n 'crm_provider_id' => $contactRole['id'],\n 'subject_type' => ContactRole::SUBJECT_TYPE_OPPORTUNITY,\n 'subject_id' => $opportunity->getId(),\n 'is_primary' => $contactRole['isPrimary'],\n 'role' => $role,\n ];\n }\n\n protected function syncRemotelyDeletedContactRoles(): bool\n {\n try {\n $deletedRemotely = $this->queryHandler->queryDeleted('OpportunityContactRole');\n } catch (NoResultsException $e) {\n return false;\n }\n\n $deletedOpportunities = $deletedRemotely->getResults();\n $deletedIds = array_column($deletedOpportunities, 'id');\n\n $contactRoleRepository = app(ContactRoleRepository::class);\n\n foreach (array_chunk($deletedIds, self::HARD_DELETE_CHUNK) as $chunk) {\n $contactRoleRepository->deleteContactRoles($chunk);\n\n $this->logger->info('[' . $this->getDisplayName() . '] Remotely deleted opportunities synced', [\n 'teamId' => $this->team->id_string,\n 'remotelyDeletedOpportunities' => $chunk,\n 'count' => count($chunk),\n ]);\n }\n\n return true;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = $objects = [];\n\n $hasWho = \\in_array($objectType, ['lead', 'contact']);\n $playbook = $this->getPlaybook($this->profile->user);\n $fields = array_merge(\n $this->profile->getFieldsAsArray(parent::OBJECT_TASK),\n $playbook && $playbook->activityField ? [$playbook->activityField->crm_provider_id] : []\n );\n\n // Query should default to any open call for that user.\n $query = '\n SELECT ' . implode(',', array_unique($fields)) . '\n FROM Task\n WHERE OwnerId = :ownerId\n AND IsArchived = false\n AND IsDeleted = false\n AND IsClosed = false\n AND (';\n\n if ($objectType === 'account') {\n // This covers tasks tied to a related contact or opportunity too.\n $query .= '\n AccountId = :accountId';\n }\n\n if ($hasWho) {\n $query .= '\n WhoId = :whoId';\n\n // If we are also going to check on a specific opportunity, set that up.\n if ($opportunityId) {\n $query .= ' OR WhatId = :whatId';\n }\n }\n\n $query .= ' ) ORDER BY LastModifiedDate DESC';\n\n try {\n $objects = $this->queryHandler->query($query, [\n 'ownerId' => $this->profile->crm_provider_id,\n 'whoId' => $objectId,\n 'whatId' => $opportunityId,\n 'accountId' => $objectId,\n ]);\n } catch (NoResultsException $e) {\n return $data;\n } finally {\n $this->logger->debug(sprintf('[Salesforce] Found %s tasks for query \"%s\"', count($objects), $query));\n }\n\n foreach ($objects as $object) {\n $dueDate = $object['ActivityDate'] ? Carbon::parse($object['ActivityDate'])->toIso8601String() : null;\n $data[] = [\n 'crmId' => $object['Id'],\n 'subject' => $object['Subject'],\n 'due' => $dueDate,\n 'type' => $object[$playbook->activityField->crm_provider_id],\n ];\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getEvents(string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = $objects = [];\n $user = $this->profile?->user;\n if ($this->profile === null || $user === null) {\n return $data;\n }\n\n $hasWho = \\in_array($objectType, ['lead', 'contact']);\n $playbook = $this->getPlaybook($user);\n $fields = array_merge(\n $this->profile->getFieldsAsArray(parent::OBJECT_EVENT),\n $playbook && $playbook->activityField ? [$playbook->activityField->crm_provider_id] : []\n );\n\n // Query should default to any event starting in the last week and ending up until today owned by the user.\n $query = '\n SELECT ' . implode(',', array_unique($fields)) . '\n FROM Event\n WHERE OwnerId = :ownerId\n AND IsArchived = false\n AND IsAllDayEvent = false\n AND StartDateTime >= LAST_N_DAYS:7\n AND EndDateTime <= TODAY\n AND (';\n\n if ($objectType === 'account') {\n // This covers events tied to a related contact or opportunity too.\n $query .= '\n AccountId = :accountId';\n }\n\n if ($hasWho) {\n $query .= '\n WhoId = :whoId';\n\n // If we are also going to check on a specific opportunity, set that up.\n if ($opportunityId) {\n $query .= ' OR WhatId = :whatId';\n }\n }\n\n $query .= ' ) ORDER BY LastModifiedDate DESC';\n\n try {\n $objects = $this->queryHandler->query($query, [\n 'ownerId' => $this->profile->crm_provider_id,\n 'whoId' => $objectId,\n 'whatId' => $opportunityId,\n 'accountId' => $objectId,\n ]);\n } catch (NoResultsException $e) {\n return $data;\n } finally {\n $this->logger->debug(sprintf('[Salesforce] Found %s tasks for query \"%s\"', count($objects), $query));\n }\n\n foreach ($objects as $object) {\n $dueDate = $object['StartDateTime'] ? Carbon::parse($object['StartDateTime'])->toIso8601String() : null;\n\n $data[] = [\n 'crmId' => $object['Id'],\n 'subject' => $object['Subject'],\n 'due' => $dueDate,\n 'type' => $object[$playbook->activityField->crm_provider_id],\n ];\n }\n\n return $data;\n }\n\n /**\n * Try to find CRM Objects using email address\n *\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchExactlyByEmail(string $email, ?int $userId = null): ?array\n {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $sosl = $queryBuilder->buildMatchByQuery($email, Field::TYPE_EMAIL);\n if ($sosl === null) {\n return null;\n }\n\n try {\n $objects = $this->queryHandler->search($sosl);\n $objects = $this->queryHandler->prioritiseResults(\n $objects,\n $email,\n QueryHandler::PRIORITISE_EMAIL\n );\n\n $data = $this->convertCrmData($objects, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (NoResultsException $e) {\n // Try the account next.\n if ($this->profile->account_fields === null) {\n return null;\n }\n }\n\n return null;\n }\n\n public function getDomain(string $email): ?string\n {\n // SF improved search - strip the domain extension, min domain name length 4\n return $this->getCompanyNameFromEmail(email: $email, minNameLength: 4);\n }\n\n /**\n * Try to find CRM objects using domain name of the email address\n *\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByDomain(string $domain, ?int $userId = null): ?array\n {\n $companyName = $domain;\n\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $sosl = $queryBuilder->buildMatchByDomainQuery($companyName);\n\n try {\n $objects = $this->queryHandler->search($sosl);\n\n $data = $this->convertCrmData($objects, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (NoResultsException) {\n return null;\n }\n }\n\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n // Don't bother looking up numbers that are masked.\n if (str_contains($phone, '**')) {\n return null;\n }\n\n if ($this->isPhoneNumberOfTeamMember($phone)) {\n return null;\n }\n\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $phoneNational = phone_national(null, $phone) ?? '';\n $possiblePhoneFormats = collect([\n preg_replace('/\\D/', '', ltrim($phone, '0+')),\n preg_replace('/\\D/', '', $phoneNational),\n formatDashPhoneNumber($phone),\n $phoneNational,\n ])\n ->filter() // Removes null and empty strings\n ->unique()\n ->values();\n\n foreach ($possiblePhoneFormats as $phone) {\n $sosl = $queryBuilder->buildMatchByQuery($phone, Field::TYPE_PHONE);\n if ($sosl === null) {\n continue;\n }\n\n try {\n $objects = $this->queryHandler->search($sosl);\n $objects = $this->queryHandler->prioritiseResults(\n $objects,\n $phone,\n QueryHandler::PRIORITISE_PHONE\n );\n\n return $this->convertCrmData($objects, $userId);\n } catch (NoResultsException) {\n continue;\n }\n }\n\n return null;\n }\n\n private function isPhoneNumberOfTeamMember(string $phone): bool\n {\n $teamRepository = app(TeamRepository::class);\n $user = $teamRepository->findTeamMemberByPhone($this->team, $phone);\n\n if ($user instanceof User) {\n return true;\n }\n\n return false;\n }\n\n protected function getCacheKey(string $object, ?int $userId = null): ?string\n {\n $key = $this->profile->id . $object;\n $keySuffix = $this->getOwnerKeySuffix($userId);\n\n return $key . $keySuffix;\n }\n\n private function getOwnerKeySuffix(?int $userId = null): string\n {\n return $userId === null ? '' : (string) $userId;\n }\n\n /** Determine the CRM Objects which represent the call activity. */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n if ($this->profile === null) {\n return null;\n }\n\n $cacheKey = $this->getCacheKey($name, $userId);\n\n $result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $sosl = $queryBuilder->buildMatchByQuery($name, 'name');\n if ($sosl === null) {\n return false;\n }\n\n try {\n $objects = $this->queryHandler->search($sosl);\n } catch (NoResultsException $e) {\n return false;\n }\n\n $objects = $this->queryHandler->prioritiseResults(\n $objects,\n $name,\n QueryHandler::PRIORITISE_NAME\n );\n\n $data = $this->convertCrmData($objects, $userId);\n\n return (! empty(array_filter($data))) ? $data : false;\n });\n\n return is_array($result) ? $result : null;\n }\n\n /**\n * @return array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n protected function convertCrmData(QueryIterator $objects, ?int $userId = null): array\n {\n $lead = null;\n $contact = null;\n $opportunity = null;\n $account = null;\n $stage = null;\n $countryCode = null;\n\n if ($objects->count() > 0) {\n $object = $objects->current();\n\n if ($object['attributes']['type'] === 'Lead') {\n $lead = $this->importLead($object);\n\n // Lead might not be imported if the Stage is null for example.\n if ($lead) {\n $countryCode = $lead->country_code;\n $stage = $lead->stage;\n }\n } else {\n if ($object['attributes']['type'] === 'Contact') {\n $contact = $this->importContact($object);\n $account = $contact->account;\n } else {\n $account = $this->importAccount($object);\n }\n\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account) {\n $countryCode = $account->country_code;\n }\n\n try {\n $sfOpportunities = $this->findOpportunities(\n $account?->getCrmProviderId(),\n $contact?->getCrmProviderId(),\n $userId\n );\n\n // Take the first opportunity, which will be ordered as priority based on their settings.\n if (! empty($sfOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($sfOpportunities[0]['crmId']);\n $stage = $opportunity?->stage;\n }\n } catch (Exception) {\n // Nothing to see here.\n }\n }\n }\n\n return [\n $lead,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n /**\n * @inheritdoc\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n if ($stage->type === Stage::TYPE_LEAD) {\n $objectType = 'Lead';\n $objectId = $crmObject->crm_provider_id;\n $objectStageType = 'Status';\n } else {\n $objectType = 'Opportunity';\n $objectId = $crmObject->crm_provider_id;\n $objectStageType = 'StageName';\n }\n\n $headers = [];\n if ($this->config->trigger_assignment_rules === false) {\n // @see: https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/headers_autoassign.htm\n $headers = [\n 'Sforce-Auto-Assign' => 'false',\n ];\n }\n\n $this->updateRecord($objectType, $objectId, [$objectStageType => $stage->name], $headers);\n }\n\n public function parseObjectType(string $objectId): string\n {\n if (Str::startsWith($objectId, '001')) {\n return 'account';\n }\n\n if (Str::startsWith($objectId, '003')) {\n return 'contact';\n }\n\n if (Str::startsWith($objectId, '00Q')) {\n return 'lead';\n }\n\n if (Str::startsWith($objectId, '006')) {\n return 'opportunity';\n }\n\n if (Str::startsWith($objectId, '00U')) {\n return 'event';\n }\n\n if (Str::startsWith($objectId, '00T')) {\n return 'task';\n }\n\n throw new \\InvalidArgumentException('Unsupported Object Type');\n }\n\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, ['profile' => $this->profile]);\n $query = $queryBuilder->buildGetUsersQuery($userToSearch);\n\n try {\n $salesforceUsers = $this->queryHandler->query($query, [\n 'active' => true,\n ]);\n } catch (NoResultsException $e) {\n $this->logger->info('[Salesforce] Sync Profiles. No users found', [\n 'query' => $query,\n 'error' => $e->getMessage(),\n ]);\n\n return null;\n }\n\n $teamRepository = app(TeamRepository::class);\n $customRules = $this->getCustomProfileRules($teamRepository);\n\n foreach ($salesforceUsers as $crmUser) {\n if ($crmUser['Email'] === null) {\n continue;\n }\n\n if (! $this->customProfileValidation($crmUser, $customRules)) {\n continue;\n }\n\n $user = $teamRepository->findActiveTeamMemberByEmail($this->team, $crmUser['Email']);\n\n if (! $user instanceof User) {\n continue;\n }\n\n $edition = $crmUser['UserPreferencesLightningExperiencePreferred']\n ? Profile::EDITION_LIGHTNING\n : Profile::EDITION_CLASSIC;\n\n $profileRepository = app(ProfileRepository::class);\n $profile = $profileRepository->updateOrCreateProfile(\n $user,\n [\n 'crm_configuration_id' => $this->config->getId(),\n 'crm_provider_id' => $crmUser['Id'],\n ],\n [\n 'user_id' => $user->getId(),\n 'edition' => $edition,\n 'has_external_cti' => ! empty($crmUser['CallCenterId']),\n 'crm_profile_id' => $crmUser['ProfileId'],\n ]\n );\n\n if ($userToSearch instanceof User && $userToSearch->getId() === $user->getId()) {\n return $profile;\n }\n }\n\n // Clean up inactive profiles\n try {\n $this->archiveInactiveProfiles();\n } catch (\\Exception $e) {\n $this->logger->warning('[Salesforce] Profile archiving failed', [\n 'teamId' => $this->team->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $url = null;\n\n // For Salesforce it's easy, we just point every object to the apex domain and they handle it.\n switch ($objectType) {\n case 'lead':\n case 'account':\n case 'contact':\n case 'opportunity':\n case 'task':\n case 'event':\n case 'activity':\n\n $url = $this->config->crm_base_url . '/' . $providerId;\n\n break;\n }\n\n return $url;\n }\n\n public function buildTaskSearchFields(): array\n {\n return ['Id', 'WhoId', 'WhatId', 'AccountId'];\n }\n\n public function getTaskByFilterConditions(\n array $fields,\n array $filters,\n bool $bulkSearch = false,\n bool $strictFilters = true\n ): ?array {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $query = $queryBuilder->buildSearchTaskQuery($fields, $filters, $bulkSearch, $strictFilters);\n\n try {\n if (! $bulkSearch) {\n $objects = $this->queryHandler->query($query, $filters);\n if ($objects->count() === 1) {\n return $objects->current();\n }\n }\n\n if ($bulkSearch) {\n $objects = $this->queryHandler->query($query);\n $records = [];\n foreach ($objects as $record) {\n $key = $record[end($fields)];\n $records[$key] = $record;\n }\n\n return $records;\n }\n } catch (\\Exception $e) {\n $this->logger->info('[Salesforce] Failed to execute query', [\n 'query' => $query,\n 'error' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function mapCrmObjects(array $task): array\n {\n $activityData = [];\n\n if (! empty($task['WhoId'])) {\n $type = $this->parseObjectType($task['WhoId']);\n $activityData[$type] = $task['WhoId'];\n }\n if (! empty($task['AccountId'])) {\n $activityData['account'] = $task['AccountId'];\n }\n if (! empty($task['WhatId'])) {\n $activityData['opportunity'] = $task['WhatId'];\n }\n\n return $activityData;\n }\n\n /**\n * Get SF task by Outreach call id.\n */\n public function getTaskByFilter(\n string $activityFieldType,\n array $filters,\n string $operator = '=',\n array $additionalFields = []\n ): ?array {\n $data = [];\n\n try {\n // Default (base) fields.\n $fields = ['Id', 'Subject', 'Description', 'ActivityDate', 'WhoId', 'WhatId', $activityFieldType];\n\n foreach ($additionalFields as $additionalField) {\n $fields[] = $additionalField->crm_provider_id;\n }\n\n $fields = array_unique($fields);\n\n // Find task with the same Outreach id as the call id.\n $query = 'SELECT ' . implode(',', $fields) . '\n FROM Task\n WHERE IsArchived = false AND IsDeleted = false';\n\n foreach ($filters as $key => $value) {\n $key = preg_quote($key, '/');\n $key = str_replace(['\\'', '\"'], '', $key);\n // Prepare the substitution.\n $strKey = \":$key\";\n\n $query .= \" AND $key $operator $strKey\";\n }\n\n $query .= ' ORDER BY LastModifiedDate DESC LIMIT 1';\n\n $objects = $this->queryHandler->query($query, $filters);\n\n // There should be only one task related to this call if any.\n if ($objects->count() === 1) {\n $object = $objects->current();\n\n $dueDate = $object['ActivityDate'] ? Carbon::parse($object['ActivityDate'])->toIso8601String() : null;\n\n $data = array_merge($object, [\n 'crmId' => $object['Id'],\n 'subject' => $object['Subject'],\n 'summary' => $object['Description'],\n 'due' => $dueDate,\n 'Type' => $object[$activityFieldType],\n ]);\n }\n } catch (NoResultsException $e) {\n // Filters don't match any records.\n } catch (ServiceUnavailableException $serviceUnavailableException) {\n // Service cannot be queried. We should probably log this.\n }\n\n return $data;\n }\n\n /**\n * Get Salesforce fields including datetime fields\n *\n * @param $objectType\n */\n private function getAllFieldsAsArray($objectType): array\n {\n $basicFields = [];\n // Not all users have access to all object fields.\n if ($this->profile->{$objectType . '_fields'}) {\n $basicFields = explode(',', $this->profile->{$objectType . '_fields'});\n }\n\n $extraFields = [\n 'CreatedDate',\n 'LastModifiedDate',\n 'IsDeleted',\n ];\n\n if ($objectType === self::OBJECT_OPPORTUNITY\n && $this->config->opportunity_value_field_id\n && ! in_array($this->config->opportunityValueField->crm_provider_id, $basicFields)\n ) {\n $extraFields[] = $this->config->opportunityValueField->crm_provider_id;\n }\n\n return array_unique(array_merge($basicFields, $extraFields));\n }\n\n /**\n * Generate transcription for activity description.\n */\n private function generateTranscription(Activity $activity): string\n {\n if (! ($this->config->store_transcript)) {\n // If sending transcription to activity toggle is disabled\n return '';\n }\n\n return $this->transcriptionService\n ->findTranscriptionByActivity($activity)\n ->map(static function (array $transcriptionSegment): string {\n return $transcriptionSegment['formattedStartsAt'] . ' | ' . $transcriptionSegment['transcript'];\n })\n ->implode(PHP_EOL);\n }\n\n /**\n * Find related Salesforce event based on activity data\n *\n * @return array<string>\n */\n public function fetchRelatedActivity(Activity $activity): array\n {\n $this->logger->info('[Salesforce] Searching for related activity', [\n 'activityId' => $activity->getUuid(),\n 'ownerId' => $this->profile?->crm_provider_id,\n ]);\n\n $sfEvent = $this->fetchRelatedEvent($activity);\n if (empty($sfEvent)) {\n $this->logger->info('[Salesforce] No related activity found', [\n 'activityId' => $activity->getUuid(),\n 'ownerId' => $this->profile?->crm_provider_id,\n 'account' => $activity->hasAccount()\n ? $activity->getAccount()->getCrmProviderId()\n : null,\n ]);\n\n return [];\n }\n\n return $sfEvent;\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n if ($activity->isTypeConference() === false) {\n return null;\n }\n\n if ($activity->hasActualStartTime() === false && $activity->hasScheduledStartTime() === false) {\n return null;\n }\n\n if (! $activity->hasProspect()) {\n $this->logger->info('[Salesforce] Skip look up, Activity not linked to Lead, Contact or Account', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n $playbook = $this->getPlaybook($activity->getUser());\n if ($playbook !== null && $playbook->getActivityType() === Playbook::ACTIVITY_TYPE_TASK) {\n $this->logger->info('[Salesforce] Skip auto-sync for task-based playbook', [\n 'activityUuid' => $activity->getUuid(),\n 'playbookId' => $playbook->getId(),\n 'playbookType' => $playbook->getActivityType(),\n ]);\n\n return null;\n }\n\n try {\n $sfEvent = $this->fetchRelatedActivity($activity);\n if (empty($sfEvent)) {\n return null;\n }\n\n [$activityField, $activityType] = $this->resolveActivityTypeFromEvent($activity, $sfEvent);\n\n $this->logger->info('[Salesforce] Found related activity', [\n 'activityId' => $activity->getUuid(),\n 'sfEvent' => $sfEvent['Id'],\n 'activityFieldName' => $activityField,\n 'crmActivityType' => ($activityField !== null && isset($sfEvent[$activityField]))\n ? $sfEvent[$activityField]\n : null,\n 'activityType' => $activityType,\n ]);\n\n $userId = $this->findRelatedActivityUserId($activity, $sfEvent);\n\n if ($activity->getUserId() !== $userId) {\n $this->logger->info('[Salesforce] Updating meeting owner', [\n 'activityId' => $activity->getUuid(),\n 'oldUserId' => $activity->getUserId(),\n 'newUserId' => $userId,\n ]);\n }\n\n $this->updateSfEventDescription($activity, $sfEvent);\n\n $activity->update([\n 'user_id' => $userId,\n 'crm_provider_id' => $sfEvent['Id'],\n 'playbook_category_id' => $activityType->id ?? $activity->getCategory()?->getId(),\n ]);\n\n $this->logger->info('[Salesforce] Activity updated', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return $activity;\n } catch (\\Exception $exception) {\n \\Sentry::captureException($exception);\n\n throw $exception;\n }\n }\n\n /**\n * @param array<string, mixed> $sfEvent\n *\n * @return array{0: string|null, 1: mixed}\n */\n private function resolveActivityTypeFromEvent(Activity $activity, array $sfEvent): array\n {\n $activityField = $this->getActivityFieldName($activity);\n $activityType = null;\n\n if ($activityField !== null && ! empty($sfEvent[$activityField])) {\n $playbook = $this->getPlaybook($activity->getUser());\n $activityType = $this->getPlaybookCategory($playbook, strval($sfEvent[$activityField]));\n }\n\n return [$activityField, $activityType];\n }\n\n /**\n * @param array<string> $sfEvent\n */\n private function findRelatedActivityUserId(Activity $activity, array $sfEvent): int\n {\n $userId = $activity->getUserId();\n\n if (empty($sfEvent['OwnerId']) === false) {\n $profile = $this\n ->config\n ->profiles()\n ->where('crm_provider_id', $sfEvent['OwnerId'])\n ->get()\n ->filter(static function (Profile $profile) use ($activity): bool {\n if (! $activity->isTypeConference()) {\n return ! empty($profile->user) ? $profile->user->isStatusActive() : false;\n }\n\n $participants = $activity->getParticipants();\n\n return ! empty($profile->user)\n ? $profile->user->isStatusActive()\n && $profile->user->hasPermission(PermissionEnum::RECORD_MEETING)\n && $participants->contains('user_id', $profile->user_id)\n : false;\n })\n ->first();\n\n if ($profile) {\n $userId = $profile->user_id;\n }\n }\n\n return $userId;\n }\n\n /**\n * @param array<string, mixed> $sfEvent\n */\n private function updateSfEventDescription(Activity $activity, array $sfEvent): void\n {\n try {\n if (str_contains($sfEvent['Description'], $activity->id_string)) {\n return;\n }\n\n $payload = [\n 'Description' => $sfEvent['Description']\n . PHP_EOL\n . PHP_EOL\n . (new DecorateActivity())->generateDescription($activity),\n ];\n\n $this->logger->info('[Salesforce] Update record', [\n 'activityId' => $activity->getUuid(),\n 'sfEvent' => $sfEvent['Id'],\n 'payload' => $payload,\n ]);\n\n $payload = array_merge(\n $payload,\n $this->payloadBuilder->fetchCustomFieldData($activity, Field::OBJECT_EVENT)\n );\n\n $this->updateRecord('Event', $sfEvent['Id'], $payload);\n } catch (\\Exception) {\n $this->logger->error('[Salesforce] Failed to update record', [\n 'activityUuid' => $activity->getUuid(),\n 'sfEvent' => $sfEvent['Id'],\n ]);\n }\n }\n\n /**\n * Returns the most recently modified Event within time range (if any).\n *\n * @return array|null An Event record from Salesforce.\n */\n private function fetchRelatedEvent(Activity $activity): ?array\n {\n $ownerId = $this->profile?->crm_provider_id;\n if ($ownerId === null) {\n return [];\n }\n\n /** @var ?Carbon $from */\n /** @var ?Carbon $to */\n [$from, $to] = $this->getFromToDates($activity);\n\n try {\n $whoId = null;\n $hasWho = $activity->lead_id || $activity->contact_id;\n if ($hasWho) {\n $whoId = $activity->hasLead()\n ? $activity->getLead()->crm_provider_id\n : $activity->getContact()->crm_provider_id;\n }\n\n if ($hasWho === false && $activity->account_id === null) {\n return null;\n }\n\n $query = $this->buildFetchRelatedEventQuery($activity);\n\n $objects = $this->queryHandler->query($query, [\n 'ownerId' => $ownerId,\n 'whoId' => $whoId,\n 'whatId' => $activity->hasOpportunity() ? $activity->getOpportunity()->crm_provider_id : null,\n 'accountId' => $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null,\n 'from' => $from?->format('Y-m-d\\TH:i:s\\Z'),\n 'to' => $to?->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($objects as $object) {\n return $object;\n }\n } catch (NoResultsException $e) {\n return [];\n }\n\n return [];\n }\n\n private function getFromToDates(Activity $activity): array\n {\n $from = null;\n $to = null;\n\n /** @var ?CalendarEvent $calendarEvent */\n $calendarEvent = $activity->calendarEvent()->first();\n if ($calendarEvent !== null) {\n $from = $calendarEvent->getStartTime();\n $to = $calendarEvent->getEndTime();\n }\n\n // For non-calendar imported activities\n // Also double check if calendar event dates could be null?\n // If null use what we've got so far\n if ($from === null || $to === null) {\n $from = $activity->hasScheduledStartTime()\n ? $activity->getScheduledStartTime()\n : $activity->getActualStartTime();\n $to = $activity->hasScheduledEndTime()\n ? $activity->getScheduledEndTime()->addMinutes(15)\n : $activity->getActualEndTime();\n }\n\n return [$from, $to];\n }\n\n /**\n * Determines the appropriate activity field name for querying Salesforce events.\n *\n * This method follows a hierarchy to determine the field name:\n * 1. Uses the playbook's activity field if it exists and is in the profile's accessible fields\n * 2. Falls back to the default activity field if the profile has no event fields configured\n * 3. Returns null if no suitable field is found\n *\n * @param Activity $activity The activity to determine the field for\n *\n * @return string|null The field name to use in queries, or null if none is available\n */\n private function getActivityFieldName(Activity $activity): ?string\n {\n if ($this->profile === null) {\n $this->logger->warning('[Salesforce] Cannot determine activity field - profile not found', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n $profileEventFields = $this->profile->getFieldsAsArray('event');\n\n if (empty($profileEventFields)) {\n $defaultActivityField = $this->getDefaultActivityField(Field::OBJECT_EVENT);\n $defaultFieldName = $defaultActivityField?->getAttribute('crm_provider_id');\n // Profile not yet synced — fall back to the default activity field.\n // There is a small chance that the profile won't have Default Activity Type field access\n // in which case the query will fail.\n // This is however an edge case and should be reviewed for profile sync issues.\n Sentry::withScope(function (\\Sentry\\State\\Scope $scope) use ($defaultFieldName): void {\n $scope->setContext('details', [\n 'profileId' => $this->profile->id,\n 'defaultField' => $defaultFieldName,\n ]);\n Sentry::captureMessage(\n '[Salesforce] Profile event fields empty, falling back to default activity field.',\n \\Sentry\\Severity::warning()\n );\n });\n\n return $defaultFieldName;\n }\n\n $playbook = $this->getPlaybook($activity->getUser());\n\n if (! is_null($playbook) && ! is_null($playbook->getActivityField())) {\n $playbookFieldName = $playbook->getActivityField()->getAttribute('crm_provider_id');\n\n if (in_array($playbookFieldName, $profileEventFields, true)) {\n return $playbookFieldName;\n }\n\n $this->logger->warning('[Salesforce] Playbook activity field not found in profile fields', [\n 'activityId' => $activity->getUuid(),\n 'playbookField' => $playbookFieldName,\n 'profileId' => $this->profile->id,\n ]);\n }\n\n return null;\n }\n\n private function buildFetchRelatedEventQuery(Activity $activity): string\n {\n $hasWho = $activity->lead_id || $activity->contact_id;\n\n $activityFieldName = $this->getActivityFieldName($activity);\n $fields = array_filter(['Id', 'Description', 'OwnerId', $activityFieldName]);\n\n $ownerCondition = '(OwnerId = :ownerId OR CreatedById = :ownerId)';\n\n $query = '\n SELECT ' . implode(',', $fields) . '\n FROM Event\n WHERE ' . $ownerCondition . '\n AND IsArchived = false\n AND IsAllDayEvent = false\n AND StartDateTime >= :from\n AND EndDateTime <= :to\n AND (';\n\n $operator = '';\n if ($activity->account_id) {\n // This covers events tied to a related contact or opportunity too.\n $query .= 'AccountId = :accountId';\n\n $operator = ' OR ';\n }\n\n if ($hasWho) {\n $query .= $operator . 'WhoId = :whoId';\n\n // If we are also going to check on a specific opportunity, set that up.\n if ($activity->opportunity_id) {\n $query .= ' OR WhatId = :whatId';\n }\n }\n\n $query .= ') ORDER BY LastModifiedDate DESC';\n\n return $query;\n }\n\n public function fetchProspect(array $task): array\n {\n $lead = $account = $opportunity = $contact = $stage = $countryCode = null;\n $externalId = $task['WhoId'] ?? null;\n\n // Lead or Contact\n if ($externalId) {\n try {\n [$lead, $account, $opportunity, $contact, $stage, $countryCode] = $this->parseRecords($externalId);\n } catch (\\InvalidArgumentException $exception) {\n // Invalid object type.\n }\n }\n\n // If we happen to know the opportunity or account from the Task, figure that out.\n if (empty($task['WhatId']) === false) {\n // WhatId could be either Account ID or Opportunity ID.\n // If WhatId is Opportunity ID, get the opportunity and stage from the CRM.\n try {\n [, $account, $opportunity, , $stage, ] = $this->parseRecords($task['WhatId']);\n } catch (\\InvalidArgumentException $exception) {\n // Invalid object type.\n }\n }\n\n return [$lead, $account, $opportunity, $contact, $stage, $countryCode];\n }\n\n /**\n * Save activity transcription summary as note\n */\n public function saveTranscriptionSummaryAsNote(\n ActivityContract $activity,\n string $title,\n string $body,\n ?string $objectId,\n ?NoteObject $noteObject = null,\n ): ?string {\n return $this->saveNote($title, $body, (string) $objectId);\n }\n\n public function getObjectByFilterConditions(string $objectType, array $fields, array $filters): ?array\n {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $query = $queryBuilder->buildObjectSearchQuery($objectType, $fields, $filters);\n\n try {\n $objects = $this->queryHandler->query($query, $filters);\n if ($objects->count() === 1) {\n return $objects->current();\n }\n } catch (\\Exception $e) {\n $this->logger->info('[Salesforce] Failed to execute query', [\n 'query' => $query,\n 'error' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n private function getCustomProfileRules(TeamRepository $teamRepository): array\n {\n $teamSettings = $teamRepository->getTeamSetting($this->team, 'custom_profile_validation');\n\n if ($teamSettings instanceof TeamSettings && $teamSettings->getValueType() === 'array') {\n $customRules = json_decode($teamSettings->getValue(), true);\n if (is_array($customRules)) {\n return $customRules;\n }\n }\n\n return [];\n }\n\n private function customProfileValidation(array $crmUser, array $customRules): bool\n {\n foreach ($customRules as $customRule) {\n if ($crmUser[$customRule['field']] !== $customRule['value']) {\n return false;\n }\n }\n\n return true;\n }\n\n /**\n * When syncing Contact / Lead / Account / Opportunity / Stage crm entities,\n * validate and restore locally trashed objects,\n * before updating them. Objects are identified by CrmProviderId\n */\n private function restoreAnyTrashedEntity(HasMany $targetEntity, string $crmProviderId): void\n {\n $recordExists = $targetEntity->withTrashed()->where(['crm_provider_id' => $crmProviderId])->first();\n if ($recordExists && $recordExists->trashed()) {\n $recordExists->restore();\n }\n }\n\n #[\\Override] public function supportsNotes(): bool\n {\n return true;\n }\n\n private function getOwnerProfile(?string $ownerId): ?Profile\n {\n if ($ownerId === null) {\n return null;\n }\n\n return $this->config->profiles()\n ->where('crm_provider_id', $ownerId)\n ->first();\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"Current version","depth":1,"on_screen":true,"value":"Current version","help_text":"text/plain","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Salesforce;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse Illuminate\\Database\\Eloquent\\Relations\\HasMany;\nuse Illuminate\\Events\\Dispatcher;\nuse Illuminate\\Support\\Facades\\Cache;\nuse Illuminate\\Support\\Facades\\Log;\nuse Illuminate\\Support\\Str;\nuse Jiminny\\Component\\Country\\CountriesMap;\nuse Jiminny\\Contracts\\Acl\\PermissionEnum;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\FetchRelatedActivityInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\ImportsBusinessProcessesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\SalesforceBatchSyncInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\SalesforceInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteNoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SearchTaskInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SendSummaryToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SupportsObjectTypeParseInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmProfileRecordTypesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Enums\\CrmObject;\nuse Jiminny\\Events\\Activities\\Crm\\LeadConverted;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpBadRequestException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Exceptions\\NoResultsException;\nuse Jiminny\\Exceptions\\ServiceUnavailableException;\nuse Jiminny\\Jobs\\Crm\\NoteObject;\nuse Jiminny\\Jobs\\Crm\\MatchActivitiesToNewOpportunity;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Calendar\\CalendarEvent;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Contracts\\ActivityContract;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Configuration;\nuse Jiminny\\Models\\Crm\\ContactRole;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Crm\\RecordType;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\TeamSettings;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\ContactRoleRepository;\nuse Jiminny\\Repositories\\Crm\\FieldRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Repositories\\Crm\\RecordTypeFieldValuesRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\Helpers\\ArrayIterator;\nuse Jiminny\\Services\\Crm\\MatchDomainByEmailInterface;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Services\\Crm\\ResolveCompanyNameByEmailTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\Fields\\FieldHelper;\nuse Jiminny\\Services\\Crm\\Salesforce\\Fields\\FieldTypeConverter;\nuse Jiminny\\Services\\Crm\\Salesforce\\Fields\\ValueNormalizer;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\FollowupActivityTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\LogActivityTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\RecordManipulationsTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\SyncFieldsTrait;\nuse Jiminny\\Utils\\CurrencyFormatter;\nuse Jiminny\\Utils\\StringUtil;\nuse Ramsey\\Uuid\\Uuid;\nuse Sentry\\Laravel\\Facade as Sentry;\n\nclass Service extends BaseService implements\n SalesforceInterface,\n SalesforceBatchSyncInterface,\n SyncCrmEntitiesInterface,\n SyncCrmProfileRecordTypesInterface,\n ImportsBusinessProcessesInterface,\n RemoteEntityManipulationInterface,\n FetchRelatedActivityInterface,\n SendSummaryToCrmInterface,\n MatchDomainByEmailInterface,\n SearchTaskInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n SupportsObjectTypeParseInterface,\n RemoteNoteEntityManipulationInterface,\n VerifyTaskExistsInterface\n{\n use ResolveCompanyNameByEmailTrait;\n use SyncFieldsTrait;\n use DeleteObjectsTrait;\n use RecordManipulationsTrait;\n use ServiceTraits\\BatchSyncTrait;\n use FollowupActivityTrait;\n use LogActivityTrait;\n\n /**\n * Note Body Limit for the Old Note-Taking Tool\n *\n * @var int\n */\n private const int CLASSIC_NOTE_MAX_LENGTH = 32000;\n\n /**\n * Note Content Limit for the New Notes\n *\n * @var int\n */\n private const int ENHANCED_NOTE_MAX_LENGTH = 50000000;\n\n private const string INSTALLED_PACKAGE_ID = '033Tw0000007bKbIAI';\n\n private const int CACHE_TTL = 600;\n\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day - 86400\n\n /**\n * @var Client\n */\n protected $client;\n\n protected PayloadBuilder $payloadBuilder;\n protected QueryHandler $queryHandler;\n\n private OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n\n public function __construct(\n Client $client,\n PayloadBuilder $payloadBuilder,\n protected Dispatcher $eventDispatcher,\n private readonly CountriesMap $countriesMap,\n private readonly ProspectPhotoPathService $prospectPhotoPathService,\n ) {\n parent::__construct();\n\n $this->client = $client;\n $this->payloadBuilder = $payloadBuilder;\n $this->queryHandler = app(QueryHandler::class, [\n 'client' => $this->client,\n 'logger' => $this->logger,\n ]);\n $this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n }\n\n public function getDisplayName(): string\n {\n return 'Salesforce';\n }\n\n public function getJobDelay(): int\n {\n return 1;\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n return $user->getSocialAccount(SocialAccount::PROVIDER_SALESFORCE);\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($activity, $crmProviderId) {\n $playbook = $this->getPlaybookFromActivity($activity);\n\n if ($playbook === null) {\n $this->logger->warning('[Salesforce] Cannot verify task - no playbook found', [\n 'activity' => $activity->getId(),\n 'crm_provider_id' => $crmProviderId,\n ]);\n\n return false;\n }\n\n $objectType = $playbook->getActivityType() === Playbook::ACTIVITY_TYPE_EVENT ? 'Event' : 'Task';\n\n try {\n $record = $this->getRecord($objectType, $crmProviderId, ['Id', 'IsDeleted']);\n\n return ! empty($record) && ($record['IsDeleted'] ?? false) === false;\n } catch (HttpNotFoundException|HttpBadRequestException) {\n $this->logger->info('[Salesforce] Activity record not found during verification', [\n 'activity' => $activity->getId(),\n 'object_type' => $objectType,\n 'crm_provider_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n });\n }\n\n public function query(string $queryToRun, array $parameters = []): QueryIterator\n {\n // Due to poorly designed external calls, this method cannot be entirely removed\n return $this->queryHandler->query($queryToRun, $parameters);\n }\n\n /*=========== Organization Information ===============*/\n\n /**\n * Get a list of all the API Versions for the instance.\n *\n * @throws CrmException\n *\n * @return mixed\n *\n */\n public function getApiVersions()\n {\n $url = $this->config->crm_base_url . '/services/data';\n\n $response = $this->client->get($url);\n\n return json_decode($response->getBody(), true);\n }\n\n /**\n * Gets the valid recordTypes for a given Salesforce Object via the describe API.\n */\n private function getRecordTypes(string $crmObject): array\n {\n $url = $this->client->getObjectsUrl() . $crmObject . '/describe';\n\n $response = $this->client->get($url);\n $jsonResponse = json_decode($response->getBody(), true);\n\n $fields = [];\n foreach ($jsonResponse['recordTypeInfos'] as $row) {\n $fields[] = ['recordTypeId' => $row['recordTypeId'], 'default' => $row['defaultRecordTypeMapping']];\n }\n\n return $fields;\n }\n\n /**\n * Convert raw field data into a format compatible with CRM APIs.\n */\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return ValueNormalizer::normalize($fieldType, $fieldValue, $internal);\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n $defaultFields = ($activityType === Playbook::ACTIVITY_TYPE_TASK)\n ? FieldDefinitions::defaultTaskFields()\n : FieldDefinitions::defaultEventFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n // Setup the activity field as the default Type.\n /** @var Field $activityField */\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'Type',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK, Playbook::ACTIVITY_TYPE_EVENT];\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n\n $fieldFilter = ($activityType === Playbook::ACTIVITY_TYPE_TASK)\n ? FieldDefinitions::taskFollowupFieldsFilter()\n : FieldDefinitions::eventFollowupFieldsFilter();\n\n foreach ($fieldFilter as $eachFilter) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $eachFilter);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n /**\n * This one is now called only when ImportActivityTypes is triggered or SyncFieldMetadata executed manually\n * Regular sync now uses SharedSyncFieldsTrait -> syncSingleObjectType\n * Needs to be replaced later on\n */\n public function syncField(Field $field): void\n {\n try {\n if (FieldHelper::isCustomField($field)) {\n $query = '\n SELECT\n Id, Metadata, TableEnumOrId\n FROM\n CustomField\n WHERE\n DeveloperName = :fieldName\n AND\n TableEnumOrId = :fieldType\n AND\n NamespacePrefix = :namespacePrefix';\n\n // We need to constrain the field lookup to the object, in case it's used in multiple places.\n $objectType = \\in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true)\n ? 'activity'\n : $field->object_type;\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'fieldName' => substr($field->crm_provider_id, 0, -\\strlen('__c')),\n 'fieldType' => ucfirst($objectType),\n\n // This is used to ensure we only consider the field within the org, not installed packages.\n 'namespacePrefix' => 'null',\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n // Sync field metadata.\n $metadata = $sfField['Metadata'];\n\n $field->description = mb_strimwidth($metadata['description'] ?? '', 0, 191);\n $field->label = mb_strimwidth($metadata['label'] ?? '', 0, Field::LABEL_MAX_LENGTH);\n $field->type = $this->convertFieldType($metadata['type'], $field->getEntityName());\n $field->is_mandatory = ($metadata['required'] === true);\n $field->length = $metadata['length'];\n $field->default_value = mb_strimwidth(trim($metadata['defaultValue'] ?? '', '\"'), 0, 191);\n $field->save();\n } else {\n $query = '\n SELECT\n Id, DataType, DeveloperName, Label, Length, Description\n FROM\n FieldDefinition\n WHERE\n DurableId = :entityName';\n\n $entityName = $field->getEntityName();\n $sfFields = $this->queryHandler->metadata($query, [\n 'entityName' => $entityName,\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n $convertedType = $this->convertFieldType($sfField['DataType'], $entityName);\n $label = mb_strimwidth($sfField['Label'], 0, Field::LABEL_MAX_LENGTH);\n\n if ($field->isBusinessType()) {\n $label = 'Opportunity Type';\n }\n\n $field->description = mb_strimwidth($sfField['Description'], 0, Field::DESCRIPTION_MAX_LENGTH);\n $field->label = $label;\n $field->type = $convertedType;\n $field->length = $sfField['Length'];\n $field->save();\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n private function convertFieldType(string $from, ?string $entityName = null): string\n {\n $converter = new FieldTypeConverter();\n\n return $converter->convert($from, $entityName);\n }\n\n /**\n * @inheritdoc\n */\n public function importPicklistValues(Field $field): array\n {\n $values = [];\n $fieldValues = [];\n\n try {\n if (FieldHelper::isCustomField($field)) {\n $query = '\n SELECT\n Id, Metadata, TableEnumOrId\n FROM\n CustomField\n WHERE\n DeveloperName = :fieldName\n AND\n TableEnumOrId = :fieldType\n AND\n NamespacePrefix = :namespacePrefix';\n\n // We need to constrain the field lookup to the object, in case it's used in multiple places.\n $objectType = \\in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true) ?\n 'activity' : $field->object_type;\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'fieldName' => substr($field->crm_provider_id, 0, -\\strlen('__c')),\n 'fieldType' => ucfirst($objectType),\n // This is used to ensure we only consider the field within the org, not installed packages.\n 'namespacePrefix' => 'null',\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n $valueSet = $sfField['Metadata']['valueSet'];\n\n if ($valueSet['valueSetName'] === null) {\n // Local picklist values can be obtained easily.\n $picklistValues = $valueSet['valueSetDefinition']['value'];\n } else {\n // But for some fields, we just get the Global Value Picklist pointer so need to do more work.\n $picklistValues = $this->importGlobalValuePicklistValues($valueSet['valueSetName']);\n }\n\n // Import all active values.\n foreach ($picklistValues as $i => $sfFieldValue) {\n // Setup default value.\n if ($sfFieldValue['default']) {\n $field->update(['default_value' => $sfFieldValue['valueName']]);\n }\n\n // This comes through as null if active (lol).\n if ($sfFieldValue['isActive'] !== false) {\n $values[] = [\n 'value' => $sfFieldValue['valueName'],\n 'label' => $sfFieldValue['valueName'],\n 'sequence' => $i,\n 'is_default' => $sfFieldValue['default'],\n ];\n }\n }\n } else {\n $objectFields = $this->getObjectFields($field->object_type);\n $fieldId = $field->crm_provider_id;\n\n // Only work with our field of interest.\n $objectField = array_filter($objectFields, function ($item) use ($fieldId) {\n return $item['name'] === $fieldId;\n });\n\n $objectField = array_shift($objectField);\n if (empty($objectField['picklistValues']) === false) {\n foreach ($objectField['picklistValues'] as $i => $sfFieldValue) {\n // Skip inactive values.\n if ($sfFieldValue['active'] === false) {\n continue;\n }\n\n // Setup default value.\n if ($sfFieldValue['defaultValue']) {\n $field->update(['default_value' => $sfFieldValue['value']]);\n }\n\n $values[] = [\n 'value' => $sfFieldValue['value'],\n 'label' => $sfFieldValue['label'],\n 'sequence' => $i,\n 'is_default' => $sfFieldValue['defaultValue'],\n ];\n }\n }\n }\n\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n $value['value'] = substr($value['value'] ?? '', 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n if (($key = array_search($value['value'], $fieldsToPurge, true)) !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n // Get IDs of the values to be deleted\n $valuesToDelete = $field->values()->whereIn('value', $fieldsToPurge);\n $valuesToDeleteIds = $valuesToDelete->pluck('id');\n if (! $valuesToDeleteIds->isEmpty()) {\n $recordTypeFieldValuesRepository = app(RecordTypeFieldValuesRepository::class);\n $recordTypeFieldValuesRepository->deleteForCrmFieldValueIds($valuesToDeleteIds->toArray());\n\n // Now safely delete from crm_field_values\n $valuesToDelete->delete();\n }\n\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n return $fieldValues;\n }\n\n /**\n * Gets values from Global Value Picklists.\n */\n private function importGlobalValuePicklistValues(string $picklistName): array\n {\n $query = '\n SELECT\n Metadata\n FROM\n GlobalValueSet\n WHERE\n DeveloperName = :picklistName\n LIMIT 1';\n\n try {\n $sfValues = $this->queryHandler->metadata($query, [\n 'picklistName' => $picklistName,\n ]);\n\n // There is always 1 result at this point.\n $sfValue = $sfValues->current();\n\n return $sfValue['Metadata']['customValue'];\n } catch (NoResultsException $noResultsException) {\n // Nothing returned.\n\n return [];\n }\n }\n\n /**\n * @inheritdoc\n */\n public function syncProfileRecordTypes(): void\n {\n $objectTypes = [\n 'lead',\n 'account',\n 'contact',\n 'opportunity',\n 'task',\n 'event',\n ];\n\n foreach ($objectTypes as $objectType) {\n try {\n $crmRecordTypes = $this->getRecordTypes(ucfirst($objectType));\n\n foreach ($crmRecordTypes as $crmRecordType) {\n // If the record type is default and not the Master type, set this.\n if ($crmRecordType['default'] && $crmRecordType['recordTypeId'] !== '012000000000000AAA') {\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $crmRecordType['recordTypeId'])\n ->first();\n\n if ($recordType) {\n $this->profile->{$objectType . '_record_type_id'} = $recordType->id;\n }\n }\n }\n } catch (HttpNotFoundException $exception) {\n Log::error('No access to ' . $objectType . ' object, skipping...');\n\n // XXX: should we log this fact somewhere?\n continue;\n }\n }\n\n if ($this->profile->isDirty()) {\n $this->profile->save();\n }\n }\n\n /**\n * Gets business processes.\n */\n public function importBusinessProcesses(): void\n {\n $query = '\n SELECT\n Id, IsActive, Name, TableEnumOrId\n FROM\n BusinessProcess\n WHERE\n TableEnumOrId IN (\\'Lead\\',\\'Opportunity\\')';\n\n try {\n $sfProcesses = $this->queryHandler->query($query);\n\n // Upsert all processes for the team.\n foreach ($sfProcesses as $sfProcess) {\n /** @var BusinessProcess $businessProcess */\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $sfProcess['Id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => $sfProcess['Name'],\n 'type' => $sfProcess['TableEnumOrId'] === 'Lead' ? 'lead' : 'opportunity',\n 'is_selectable' => $sfProcess['IsActive'],\n ]);\n\n $this->importBusinessProcessStages($businessProcess);\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n /**\n * Gets business process stages.\n */\n private function importBusinessProcessStages(BusinessProcess $businessProcess): void\n {\n $query = '\n SELECT\n Metadata\n FROM\n BusinessProcess\n WHERE\n Id = :processId';\n\n try {\n $stages = [];\n $sfProcessStages = $this->queryHandler->metadata($query, [\n 'processId' => $businessProcess->crm_provider_id,\n ]);\n\n // There is always 1 result at this point.\n $sfProcessStage = $sfProcessStages->current();\n\n // Upsert all processes for the team.\n foreach ($sfProcessStage['Metadata']['values'] as $sfProcessStage) {\n $sanitizedName = urldecode($sfProcessStage['valueName']); // Must decode: \"%2C\" becomes \",\" etc.\n\n $stage = $businessProcess->crm->stages()\n // This MUST match on label because this API doesn't use API Name.\n ->where('label', $sanitizedName)\n ->where('type', $businessProcess->type)\n ->where('is_selectable', 1)\n ->first();\n\n if ($stage) {\n $stages[] = $stage->id;\n }\n }\n\n $businessProcess->stages()->sync($stages);\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n /**\n * Gets record types.\n */\n public function importRecordTypes(): void\n {\n $query = '\n SELECT\n Id, IsActive, Name, BusinessProcessId, SobjectType\n FROM\n RecordType';\n\n try {\n $sfRecordTypes = $this->queryHandler->query($query);\n\n // Upsert all record types for the process.\n foreach ($sfRecordTypes as $sfRecordType) {\n $businessProcess = null;\n if ($sfRecordType['BusinessProcessId']) {\n $businessProcess = $this->config->businessProcesses()\n ->where('crm_provider_id', $sfRecordType['BusinessProcessId'])\n ->first();\n }\n\n /** @var RecordType $recordType */\n $recordType = $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $sfRecordType['Id'],\n ], [\n 'team_id' => $this->team->id,\n 'type' => mb_strtolower($sfRecordType['SobjectType']),\n 'name' => $sfRecordType['Name'],\n 'is_selectable' => $sfRecordType['IsActive'],\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n $this->importRecordTypeFieldValues($recordType);\n }\n } catch (NoResultsException $noResultsException) {\n // Do nothing.\n }\n }\n\n /**\n * Import record type - field value mappings. This only works for standard fields.\n */\n private function importRecordTypeFieldValues(RecordType $recordType): void\n {\n try {\n $query = '\n SELECT\n Metadata\n FROM\n RecordType\n WHERE\n Id = :recordTypeId';\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'recordTypeId' => $recordType->crm_provider_id,\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n // Sync field metadata.\n $picklists = $sfField['Metadata']['picklistValues'];\n\n foreach ($picklists as $picklist) {\n $field = $this->config->fields()->where([\n 'type' => Field::TYPE_PICKLIST,\n 'object_type' => $recordType->type,\n 'crm_provider_id' => $picklist['picklist'],\n ])->first();\n\n if ($field) {\n $fieldValues = [];\n\n foreach ($picklist['values'] as $value) {\n // Must decode: \"%2C\" becomes \",\" etc.\n $fieldValue = $field->values()\n ->where('value', urldecode($value['valueName']))\n ->first();\n\n if ($fieldValue) {\n $fieldValues[] = $fieldValue->id;\n }\n }\n\n $recordType->fieldValues()->sync($fieldValues);\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n /**\n * @inheritdoc\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $params = [];\n $missingStage = null;\n if ($types === null) {\n $types = [Stage::TYPE_LEAD, Stage::TYPE_OPPORTUNITY];\n }\n\n foreach ($types as $type) {\n if ($type === Stage::TYPE_LEAD) {\n $query = '\n SELECT\n Id, ApiName, MasterLabel, SortOrder\n FROM\n LeadStatus';\n } else {\n $query = '\n SELECT\n Id, ApiName, MasterLabel, IsActive, SortOrder, DefaultProbability\n FROM\n OpportunityStage';\n }\n\n if ($missingStageName) {\n $escapedStageName = ValueNormalizer::replaceQueryWithStringLiterals($missingStageName);\n\n $query .= ' WHERE ApiName = :stageName';\n\n $params = [\n 'stageName' => $escapedStageName,\n ];\n }\n\n try {\n $sfStages = $this->queryHandler->query($query, $params);\n } catch (NoResultsException $exception) {\n $sfStages = [];\n }\n\n $missingStage = null;\n\n // Upsert all stages for the team.\n foreach ($sfStages as $sfStage) {\n $selectable = true;\n if (array_key_exists('IsActive', $sfStage)) {\n $selectable = $sfStage['IsActive'];\n }\n\n $this->restoreAnyTrashedEntity($this->config->stages(), $sfStage['Id']);\n\n $stage = $this->config->stages()->updateOrCreate([\n 'crm_provider_id' => $sfStage['Id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($sfStage['ApiName'], 0, 50),\n 'label' => mb_strimwidth($sfStage['MasterLabel'], 0, 191),\n 'type' => $type,\n 'sequence' => $sfStage['SortOrder'] ?? 0,\n 'is_selectable' => $selectable,\n 'probability' => $sfStage['DefaultProbability'] ?? null,\n ]);\n\n if ($missingStageName && $missingStageName === $sfStage['ApiName']) {\n $missingStage = $stage;\n }\n }\n\n if ($missingStageName && $missingStage === null) {\n // If they requested a stage that still doesn't exist, it must be inactive so lazy create it.\n $missingStage = $this->config->stages()->create([\n 'crm_provider_id' => Uuid::uuid4(),\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($missingStageName, 0, 50),\n 'label' => mb_strimwidth($missingStageName, 0, 191),\n 'type' => $type,\n 'sequence' => 0,\n 'is_selectable' => 0,\n ]);\n }\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int\n {\n $syncCount = 0;\n $fields = $this->getAllFieldsAsArray('lead');\n if (\\in_array('Id', $fields, true) === false) {\n return $syncCount;\n }\n\n $query = '\n SELECT ' . rtrim(implode(',', $fields), ',') . '\n FROM Lead\n WHERE LastModifiedDate > :since\n ORDER BY LastModifiedDate ASC';\n\n try {\n $sfLeads = $this->queryHandler->query($query, [\n 'since' => $since->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($sfLeads as $sfLead) {\n // Only sync if previously imported.\n if ($this->hasLead($sfLead['Id'])) {\n $this->importLead($sfLead);\n $syncCount++;\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::LEAD);\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLead(string $crmId): ?Lead\n {\n $fields = $this->getAllFieldsAsArray('lead');\n\n $sfLead = $this->getRecord('Lead', $crmId, $fields);\n\n return $this->importLead($sfLead);\n }\n\n private function importLead($crmData): ?Lead\n {\n /** @var ?Stage $stage */\n $stage = null;\n if (isset($crmData['Status'])) {\n // Get the current stage.\n $stage = $this->config\n ->stages()\n ->where('name', $crmData['Status'])\n ->where('type', Stage::TYPE_LEAD)\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_LEAD], $crmData['Status']);\n }\n }\n\n // If we have no way of importing this, just return null :(\n if ($stage === null) {\n return null;\n }\n\n $countryCode = $crmData['CountryCode'] ?? null;\n\n // Salesforce allows custom \"countries\" to be created. Disregard these.\n if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {\n $countryCode = null;\n }\n\n // If we have no country code, try to parse it from the country name.\n if ($countryCode === null && empty($crmData['Country']) !== false) {\n $countryCode = $this->convertCountryNameToCode($crmData['Country']);\n }\n\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($crmData['Phone'] ?? '', 0, 25);\n $parsedNumber = parsePhoneNumber($countryCode, $number);\n\n $mobilePhone = null;\n if (empty($crmData['MobilePhone']) === false) {\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($crmData['MobilePhone'], 0, 25);\n $mobilePhone = phone_e164($countryCode, $number);\n }\n\n $convertedDate = null;\n $convertedAccount = null;\n $convertedOpportunity = null;\n $convertedContact = null;\n\n if ($crmData['IsConverted'] == 'true') {\n $convertedDate = $crmData['ConvertedDate'];\n\n if (empty($crmData['ConvertedAccountId']) === false) {\n $convertedAccount = $this->config\n ->accounts()\n ->where('crm_provider_id', $crmData['ConvertedAccountId'])\n ->first();\n\n if ($convertedAccount === null) {\n try {\n $convertedAccount = $this->syncAccount($crmData['ConvertedAccountId']);\n } catch (HttpNotFoundException $exception) {\n // Probably the user has no permissions to access the converted data.\n }\n }\n }\n\n if (empty($crmData['ConvertedOpportunityId']) === false) {\n $convertedOpportunity = $this->config\n ->opportunities()\n ->where('crm_provider_id', $crmData['ConvertedOpportunityId'])\n ->first();\n\n if ($convertedOpportunity === null) {\n try {\n $convertedOpportunity = $this->syncOpportunity($crmData['ConvertedOpportunityId']);\n } catch (HttpNotFoundException $exception) {\n // Probably the user has no permissions to access the converted data.\n }\n }\n }\n\n if (empty($crmData['ConvertedContactId']) === false) {\n $convertedContact = $this->team\n ->crm\n ->contacts()\n ->where('crm_provider_id', $crmData['ConvertedContactId'])\n ->first();\n\n if ($convertedContact === null) {\n try {\n $convertedContact = $this->syncContact($crmData['ConvertedContactId']);\n } catch (HttpNotFoundException $exception) {\n // Probably the user has no permissions to access the converted data.\n }\n }\n }\n }\n\n if (empty($crmData['Company'])) {\n $company = 'Unknown';\n } else {\n $company = mb_strimwidth($crmData['Company'], 0, 191);\n }\n\n $domain = null;\n if (empty($crmData['Website']) === false) {\n $domain = mb_strimwidth($crmData['Website'], 0, 191);\n $domain = StringUtil::resolveDomain($domain);\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $data = [\n 'team_id' => $this->team->id,\n 'user_id' => $profile?->user_id,\n 'owner_id' => $crmData['OwnerId'] ?? '',\n 'company' => $company,\n 'domain' => $domain,\n 'name' => $crmData['Name'] ? mb_strimwidth($crmData['Name'], 0, 191) : '',\n 'title' => $crmData['Title'] ? mb_strimwidth($crmData['Title'], 0, 128) : null,\n 'email' => $crmData['Email'] ? mb_strimwidth($crmData['Email'], 0, 80) : null,\n 'phone' => $parsedNumber['phone'],\n 'ext' => $parsedNumber['ext'] ?? null,\n 'mobile_phone' => $mobilePhone,\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['Id'],\n modelType: Lead::class,\n fileName: $crmData['Id'],\n avatarText: $crmData['Name']\n ),\n 'stage_id' => $stage->id,\n 'record_type_id' => null,\n 'converted_at' => $convertedDate,\n 'converted_account_id' => $convertedAccount->id ?? null,\n 'converted_opportunity_id' => $convertedOpportunity->id ?? null,\n 'converted_contact_id' => $convertedContact->id ?? null,\n 'country_code' => $countryCode,\n 'remotely_created_at' => $createdDate,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->leads(), $crmData['Id']);\n\n /** @var Lead $lead */\n $lead = $this->config->leads()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n if ($lead->wasChanged('converted_at') && $lead->getConvertedAt() !== null) {\n $this->eventDispatcher->dispatch(new LeadConverted($lead));\n }\n\n $this->handleObjectDeletion($lead, $crmData);\n\n if ($lead->trashed()) {\n return null;\n }\n\n return $lead;\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccounts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n $fields = $this->getAllFieldsAsArray('account');\n\n if (\\in_array('Id', $fields, true) === false) {\n return $syncCount;\n }\n\n $query = '\n SELECT ' . rtrim(implode(',', $fields), ',') . '\n FROM Account\n WHERE LastModifiedDate > :since\n ORDER BY LastModifiedDate ASC';\n\n try {\n $sfAccounts = $this->queryHandler->query($query, [\n 'since' => $since->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($sfAccounts as $sfAccount) {\n // Only sync if previously imported.\n if ($this->hasAccount($sfAccount['Id'])) {\n $this->importAccount($sfAccount);\n $syncCount++;\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::ACCOUNT);\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccount(string $crmId): ?Account\n {\n $fields = $this->getAllFieldsAsArray('account');\n if (! in_array('Id', $fields, true)) {\n $this->logger->info('[Salesforce] Sync account cancelled. Fields are not available.', [\n 'crmId' => $crmId,\n 'userId' => $this->profile->getUserId(),\n ]);\n\n return null;\n }\n\n $sfAccount = $this->getRecord('Account', $crmId, $fields);\n\n return $this->importAccount($sfAccount);\n }\n\n private function importAccount($crmData): Account\n {\n $countryCode = $crmData['BillingCountryCode'] ?? $crmData['ShippingCountryCode'] ?? null;\n\n // Salesforce allows custom \"countries\" to be created. Disregard these.\n if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {\n $countryCode = null;\n }\n\n // If we have no country code, try to parse it from the country names.\n if ($countryCode === null && empty($crmData['BillingCountry']) === false) {\n $countryCode = $this->convertCountryNameToCode($crmData['BillingCountry']);\n }\n\n if ($countryCode === null && empty($crmData['ShippingCountry']) === false) {\n $countryCode = $this->convertCountryNameToCode($crmData['ShippingCountry']);\n }\n\n if (empty($crmData['Phone']) === false) {\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($crmData['Phone'], 0, 25);\n $parsedNumber = parsePhoneNumber($countryCode, $number);\n } else {\n $parsedNumber = [];\n }\n\n $industry = null;\n if (empty($crmData['Industry']) === false) {\n $industry = mb_strimwidth($crmData['Industry'], 0, 40);\n }\n\n $domain = null;\n if (empty($crmData['Website']) === false) {\n $domain = mb_strimwidth($crmData['Website'], 0, 191);\n $domain = StringUtil::resolveDomain($domain);\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $data = [\n 'team_id' => $this->team->id,\n 'user_id' => $profile?->user_id,\n 'owner_id' => $crmData['OwnerId'],\n 'name' => mb_strimwidth($crmData['Name'], 0, 191),\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['Id'],\n modelType: Account::class,\n fileName: $crmData['Id'],\n avatarText: $crmData['Name']\n ),\n 'industry' => $industry,\n 'domain' => $domain,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'ext' => $parsedNumber['ext'] ?? null,\n 'country_code' => $countryCode,\n 'remotely_created_at' => $createdDate,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->accounts(), $crmData['Id']);\n\n /** @var Account $account */\n $account = $this->config->accounts()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n $this->handleObjectDeletion($account, $crmData);\n\n return $account;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOpportunities(array $parameters, ?string $strategy = null): int\n {\n $strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);\n\n $syncCount = 0;\n $logParams = $parameters;\n $parameters['profile'] = $this->profile;\n $logParams['user'] = $this->profile->getUserId();\n\n if (count($strategies) > 1) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Multiple sync strategies used', [\n 'teamId' => $this->team->getUuid(),\n 'params' => $logParams,\n 'strategies_count' => count($strategies),\n ]);\n }\n\n foreach ($strategies as $syncStrategy) {\n $name = $syncStrategy->getStrategyName();\n\n try {\n $sfOpportunities = $syncStrategy->fetchOpportunities($parameters);\n $totalRecords = $sfOpportunities->count();\n\n foreach ($sfOpportunities as $sfOpportunity) {\n $this->importOpportunity($sfOpportunity);\n $syncCount++;\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n $this->logger->warning('[' . $this->getDisplayName() . '] No opportunities found', [\n 'teamId' => $this->team->getUuid(),\n 'name' => $name,\n 'params' => $logParams,\n 'reason' => $noResultsException->getMessage(),\n ]);\n } catch (CrmException $crmException) {\n // Nothing to sync.\n $this->logger->warning('[' . $this->getDisplayName() . '] Opportunity sync failed', [\n 'teamId' => $this->team->getUuid(),\n 'name' => $name,\n 'params' => $logParams,\n 'reason' => $crmException->getMessage(),\n ]);\n }\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::OPPORTUNITY, ['params' => $logParams]);\n\n // debug to see how if count of opportunities reaches 1000\n if ($syncCount >= 1000) {\n $this->logger->info(\n '[' . $this->getDisplayName() . '] Sync Opportunities - count warning',\n [\n 'team_id' => $this->team->getId(),\n 'params' => $logParams,\n 'count' => $syncCount,\n 'strategies_count' => count($strategies),\n 'total_records' => $totalRecords ?? null,\n ]\n );\n }\n\n return $syncCount;\n }\n\n\n /**\n * @inheritdoc\n */\n public function syncOpportunity(string $crmId): ?Opportunity\n {\n $strategy = $this->opportunitySyncStrategyResolver->resolve(\n $this->config,\n OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY\n );\n\n $parameters = [\n 'profile' => $this->profile,\n 'crm_id' => $crmId,\n ];\n\n try {\n $sfOpportunity = $strategy->fetchOpportunities($parameters);\n } catch (HttpNotFoundException $e) {\n $this->logger->info('[' . $this->getDisplayName() . '] Opportunity not found', [\n 'teamId' => $this->team->id_string,\n 'crmId' => $crmId,\n ]);\n\n return null;\n } catch (CrmException $crmException) {\n $this->logger->info('[' . $this->getDisplayName() . '] Opportunity sync failed', [\n 'teamId' => $this->team->id_string,\n 'crmId' => $crmId,\n 'exception' => $crmException->getMessage(),\n ]);\n\n return null;\n }\n\n if ($sfOpportunity instanceof ArrayIterator) {\n return $this->importOpportunity($sfOpportunity->getItems());\n }\n\n return $this->importOpportunity($sfOpportunity);\n }\n\n /**\n * @throws HttpNotFoundException\n */\n private function importOpportunity($crmData): ?Opportunity\n {\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $account = null;\n if (empty($crmData['AccountId']) === false) {\n /** @var ?Account $account */\n $account = $this->config->accounts()\n ->where('crm_provider_id', (string) $crmData['AccountId'])\n ->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmData['AccountId']);\n }\n }\n\n $userId = $profile?->getUserId() ?? $account?->getUserId();\n if ($userId === null) {\n $this->logger->error('[Salesforce] | Skip import, no user_id found', [\n 'id' => $crmData['Id'],\n ]);\n\n return null;\n }\n\n /** @var ?Stage $stage */\n $stage = null;\n if (isset($crmData['StageName'])) {\n $stage = $this->config\n ->stages()\n ->where('name', $crmData['StageName'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->orderBy('is_selectable', 'DESC')\n ->orderBy('id')\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $crmData['StageName']);\n }\n }\n\n $recordType = null;\n if (empty($crmData['RecordTypeId']) === false) {\n /** @var ?RecordType $recordType */\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $crmData['RecordTypeId'])\n ->first();\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $closeDate = null;\n if (empty($crmData['CloseDate']) === false) {\n $closeDate = Carbon::parse($crmData['CloseDate'])->format('Y-m-d');\n }\n\n $valueFieldName = 'Amount';\n if ($this->config->opportunity_value_field_id) {\n $valueFieldName = $this->config->opportunityValueField->crm_provider_id;\n }\n\n $data = [\n 'team_id' => $this->team->id,\n 'account_id' => $account->id ?? null,\n 'user_id' => $userId,\n 'owner_id' => $crmData['OwnerId'] ?? null,\n 'name' => mb_strimwidth($crmData['Name'] ?? '', 0, 128),\n 'value' => $crmData[$valueFieldName],\n 'currency_code' => CurrencyFormatter::formatCode($crmData['CurrencyIsoCode'] ?? null),\n 'close_date' => $closeDate,\n 'is_closed' => $crmData['IsClosed'],\n 'is_won' => $crmData['IsWon'],\n 'stage_id' => $stage?->id ?? null,\n 'record_type_id' => $recordType->id ?? null,\n 'remotely_created_at' => $createdDate,\n 'probability' => $crmData['Probability'] ?? null,\n 'forecast_category' => $crmData['ForecastCategoryName'] ?? null,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->opportunities(), $crmData['Id']);\n\n // Do not allow locked DB tables & other errors\n // to interrupt the process of reverting the trashed opportunities\n try {\n /** @var Opportunity $opportunity */\n $opportunity = $this->config->opportunities()\n ->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n // import external fields into crm_field_data if present\n $crmFields = $this->getOpportunitySyncableFields();\n\n $this->importOpportunityCrmFieldData($crmData, $crmFields, $opportunity->id);\n\n $this->handleObjectDeletion($opportunity, $crmData);\n\n if ($opportunity->trashed()) {\n return null;\n }\n\n if ($opportunity->wasRecentlyCreated) {\n MatchActivitiesToNewOpportunity::dispatch($opportunity->getId());\n }\n\n return $opportunity;\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n $this->logger->error('[Salesforce] importOpportunity failure.', [\n 'crm_provider_id' => $crmData['Id'],\n 'team_id' => $this->team->id,\n 'exception' => $exception->getMessage(),\n ]);\n\n $this->handleEntityDeletionByProviderId($this->config->opportunities(), $crmData);\n }\n\n return null;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContacts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n $fields = $this->getAllFieldsAsArray('contact');\n if (\\in_array('Id', $fields, true) === false) {\n return $syncCount;\n }\n\n $query = '\n SELECT ' . rtrim(implode(',', $fields), ',') . '\n FROM Contact\n WHERE LastModifiedDate > :since\n ORDER BY LastModifiedDate ASC';\n\n try {\n $sfContacts = $this->queryHandler->query($query, [\n 'since' => $since->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($sfContacts as $sfContact) {\n // Only sync if previously imported.\n if ($this->hasContact($sfContact['Id'])) {\n $this->importContact($sfContact);\n $syncCount++;\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::CONTACT);\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContact(string $crmId): ?Contact\n {\n $fields = $this->getAllFieldsAsArray('contact');\n if (! in_array('Id', $fields, true)) {\n $this->logger->info('[Salesforce] Sync contact cancelled. Fields are not available.', [\n 'crmId' => $crmId,\n 'userId' => $this->profile->getUserId(),\n ]);\n\n return null;\n }\n\n $sfContact = $this->getRecord('Contact', $crmId, $fields);\n\n return $this->importContact($sfContact);\n }\n\n private function importContact($crmData): ?Contact\n {\n $account = null;\n // Contacts may not have accounts...\n if (isset($crmData['AccountId'])) {\n $account = $this->config->accounts()\n ->where('crm_provider_id', (string) $crmData['AccountId'])\n ->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmData['AccountId']);\n }\n }\n\n $countryCode = $crmData['MailingCountryCode'] ?? null;\n\n // Salesforce allows custom \"countries\" to be created. Disregard these.\n if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {\n $countryCode = null;\n }\n\n // If we have no country code, try to parse it from the country name.\n if ($countryCode === null && empty($crmData['MailingCountry']) === false) {\n $countryCode = $this->convertCountryNameToCode($crmData['MailingCountry']);\n\n if ($countryCode === null && $account) {\n $countryCode = $account->country_code;\n }\n }\n\n $ext = null;\n $parsedNumber = [];\n if (empty($crmData['Phone']) === false) {\n $number = Str::limit($crmData['Phone'], 25, '');\n $parsedNumber = parsePhoneNumber($countryCode, $number);\n\n if (empty($parsedNumber['ext']) === false) {\n $ext = Str::limit($parsedNumber['ext'], 10, '');\n }\n }\n\n $mobileNumber = null;\n if (empty($crmData['MobilePhone']) === false) {\n $mobileNumber = Str::limit(phone_e164($countryCode, $crmData['MobilePhone']), 25, '');\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $data = [\n 'team_id' => $this->team->id,\n 'account_id' => $account->id ?? null,\n 'user_id' => $profile?->user_id,\n 'owner_id' => $crmData['OwnerId'] ?? null,\n 'name' => ($crmData['Name'] ?? null) !== null ? mb_strimwidth($crmData['Name'], 0, 100) : '',\n 'title' => ($crmData['Title'] ?? null) !== null ? mb_strimwidth($crmData['Title'], 0, 128) : null,\n 'email' => ($crmData['Email'] ?? null) !== null ? mb_strimwidth($crmData['Email'], 0, 191) : null,\n 'country_code' => $countryCode,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'ext' => $ext,\n 'mobile_phone' => $mobileNumber,\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['Id'],\n modelType: Contact::class,\n fileName: $crmData['Id'],\n avatarText: $crmData['Name']\n ),\n 'remotely_created_at' => $createdDate,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->contacts(), $crmData['Id']);\n\n /** @var Contact $contact */\n $contact = $this->config->contacts()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n $this->handleObjectDeletion($contact, $crmData);\n\n if ($contact->trashed()) {\n return null;\n }\n\n return $contact;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n $fields = [\n 'InstanceName',\n 'OrganizationType',\n 'IsSandbox',\n ];\n\n $orgValues = $this->getRecord('Organization', $this->config->crm_provider_id, $fields);\n\n $edition = null;\n switch ($orgValues['OrganizationType']) {\n case 'Developer Edition':\n $edition = Configuration::EDITION_DEVELOPER;\n\n break;\n\n case 'Professional Edition':\n $edition = Configuration::EDITION_PROFESSIONAL;\n\n break;\n\n case 'Enterprise Edition':\n $edition = Configuration::EDITION_ENTERPRISE;\n\n break;\n }\n\n $this->config->edition = $edition;\n $this->config->instance = $orgValues['InstanceName'];\n\n // XXX: How can this state be possible?\n if ($this->config->version === null) {\n $this->config->version = Client::MIN_API_VERSION;\n }\n\n $installedVersion = $this->getInstalledAppVersion();\n if ($installedVersion !== null) {\n $installedVersion = (string) $this->getInstalledAppVersion();\n }\n\n $this->config->installed_app_version = $installedVersion;\n\n $this->config->save();\n }\n\n public function getInstalledAppVersion(): ?string\n {\n try {\n $query = '\n SELECT\n SubscriberPackageVersion.MajorVersion,\n SubscriberPackageVersion.MinorVersion,\n SubscriberPackageVersion.PatchVersion,\n SubscriberPackageVersion.BuildNumber\n FROM\n InstalledSubscriberPackage\n WHERE\n SubscriberPackageId = :packageId\n ';\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'packageId' => self::INSTALLED_PACKAGE_ID,\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n // Grab version number.\n $version = $sfField['SubscriberPackageVersion']['MajorVersion'] .\n $sfField['SubscriberPackageVersion']['MinorVersion'] .\n $sfField['SubscriberPackageVersion']['PatchVersion'] .\n $sfField['SubscriberPackageVersion']['BuildNumber'];\n } catch (\\Exception) {\n $version = null;\n }\n\n return $version;\n }\n\n /**\n * Store transcripts as note.\n *\n * @throws \\Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // For SF we also check if Log Notes is enabled.\n if ($this->profile->log_notes === Profile::LOG_NOTE_NONE) {\n return;\n }\n\n if ($activity->opportunity_id && $activity->prospect === null) {\n return;\n }\n\n try {\n $transcriptionData = $this->generateTranscription($activity);\n\n $noteMaxLength = $this->profile->log_notes === Profile::LOG_NOTE_ENHANCED\n ? self::ENHANCED_NOTE_MAX_LENGTH\n : self::CLASSIC_NOTE_MAX_LENGTH;\n\n $title = 'Transcript for ';\n $title .= $activity->title ?? $activity->activity_title;\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $body = mb_strimwidth($transcriptionData, 0, $noteMaxLength);\n\n if ($activity->opportunity_id) {\n $objectId = $activity->opportunity->crm_provider_id;\n } else {\n $objectId = $activity->prospect->crm_provider_id;\n }\n\n $noteId = $this->saveNote($title, $body, $objectId);\n\n // Store crm logged id in transcription.\n $transcription = $activity->getTranscription();\n $transcription->crm_activity_id = $noteId;\n $transcription->save();\n } catch (\\Exception $e) {\n \\Sentry::captureException($e);\n }\n }\n\n public function saveNote(string $title, string $body, string $objectId, ?NoteObject $noteObject = null): ?string\n {\n $noteId = null;\n\n try {\n if ($this->profile->log_notes === Profile::LOG_NOTE_ENHANCED) {\n $noteId = $this->buildEnhancedNote($title, $body, $objectId);\n } else {\n $noteId = $this->buildClassicNote($title, $body, $objectId);\n }\n } catch (HttpNotFoundException $exception) {\n // The profile not having access to create Enhanced Notes. Set their preference to Classic.\n if ($this->profile->log_notes === Profile::LOG_NOTE_ENHANCED) {\n $this->profile->update([\n 'log_notes' => Profile::LOG_NOTE_CLASSIC,\n ]);\n }\n }\n\n return $noteId;\n }\n\n /**\n * This is using the \"Enhanced\" Notes feature, NOT the \"Notes & Attachments\" feature being deprecated.\n *\n * @url https://salesforce.stackexchange.com/questions/104408/how-can-i-create-an-account-note-or-contact-note-via-api-that-is-visible-in-sale\n */\n private function buildEnhancedNote(string $title, string $body, string $objectId): string\n {\n // Decode stored entities, escape HTML (without quoting), then convert line breaks for Salesforce formatting\n $decodedBody = html_entity_decode($body, ENT_QUOTES | ENT_HTML5);\n $sanitizedBody = htmlspecialchars($decodedBody, ENT_NOQUOTES, 'UTF-8', false);\n $content = nl2br($sanitizedBody, false);\n $note = [\n 'OwnerId' => $this->profile->crm_provider_id,\n 'Title' => $title,\n 'Content' => base64_encode($content),\n ];\n\n $noteId = $this->createRecord('ContentNote', $note);\n\n $link = [\n 'ContentDocumentId' => $noteId,\n 'LinkedEntityId' => $objectId,\n 'ShareType' => 'I',\n ];\n\n $this->createRecord('ContentDocumentLink', $link);\n\n return $noteId;\n }\n\n private function buildClassicNote(string $title, string $body, string $objectId): string\n {\n if (in_array($this->parseObjectType($objectId), [Field::OBJECT_TASK, Field::OBJECT_EVENT])) {\n $this->logger->info('[Salesforce] Summary not sent', [\n 'profile_id' => $this->profile->id,\n 'objectId' => $objectId,\n 'reason' => 'Classical Note does not support Task/Event relation',\n ]);\n\n return '';\n }\n\n $titleTrimmed = null;\n\n if (mb_strlen($title) > 80) {\n $titleTrimmed = substr($title, 0, 77) . '...';\n }\n $payload = [\n 'OwnerId' => $this->profile->crm_provider_id,\n 'IsPrivate' => false,\n 'Title' => $titleTrimmed ?? $title,\n 'Body' => $titleTrimmed ? $title . PHP_EOL . $body : $body,\n 'ParentId' => $objectId,\n ];\n\n return $this->createRecord('Note', $payload);\n }\n\n /**\n * @inheritdoc\n */\n public function find(string $name, array $scopes): array\n {\n if ($this->profile === null) {\n return [];\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $limitValues = ['limit' => $this->limit, 'offset' => $this->offset];\n $sosl = $queryBuilder->buildFindQuery($name, $scopes, $limitValues);\n\n $this->logger->info('[Salesforce] Find prospects', [\n 'profile_id' => $this->profile->id,\n 'sosl_query' => $sosl,\n 'search_string' => $name,\n 'scopes' => $scopes,\n ]);\n\n $data = Cache::remember($this->profile->id . $sosl, self::CACHE_TTL, function () use ($sosl) {\n $data = [];\n\n try {\n // Hit remote API.\n $objects = $this->queryHandler->search($sosl);\n\n // Build mapped list.\n foreach ($objects as $object) {\n $type = strtolower($object['attributes']['type']);\n\n $record = [\n 'crmId' => $object['Id'],\n 'name' => $object['Name'],\n 'prospectType' => $type,\n 'phoneNumbers' => [],\n 'crmUrl' => $this->generateProviderUrl($object['Id'], $type),\n ];\n\n switch ($type) {\n case 'lead':\n if (empty($object['Company']) === false) {\n $record['organization'] = $object['Company'];\n }\n\n if (empty($object['Title']) === false) {\n $record['title'] = $object['Title'];\n }\n\n $stage = $this->config->stages()\n ->where('type', Stage::TYPE_LEAD)\n ->where('name', $object['Status'])\n ->first();\n\n // Lazy create the stage.\n if ($stage === null) {\n $stage = $this->importStages([Stage::TYPE_LEAD], $object['Status']);\n }\n\n if ($stage) {\n $record += [\n 'stage' => [\n 'id' => $stage->id_string,\n 'name' => $stage->name,\n ],\n ];\n }\n\n if (empty($object['RecordTypeId']) === false) {\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $object['RecordTypeId'])\n ->first();\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n }\n\n break;\n\n case 'account':\n if (empty($object['Industry']) === false) {\n $record['industry'] = $object['Industry'];\n $record['detailsLine'] = $object['Industry'];\n }\n if (! empty($object['PersonEmail'])) {\n $record['detailsLine'] = $object['PersonEmail'];\n }\n\n break;\n\n case 'contact':\n // For contacts, we should try and fetch their account name too.\n if ($object['AccountId']) {\n // Cheaper to get this locally.\n $account = $this->config->accounts()\n ->where('crm_provider_id', $object['AccountId'])\n ->first(['name']);\n\n if ($account) {\n $record['organization'] = $account->name;\n }\n }\n\n if (! empty($object['IsPersonAccount']) && $object['Email']) {\n $record['detailsLine'] = $object['Email'];\n } else {\n if (empty($object['Title']) === false) {\n $record['title'] = $object['Title'];\n }\n }\n\n break;\n }\n\n // Add phone numbers to record.\n if (empty($object['Phone']) === false && $object['Phone']) {\n $record['phoneNumbers'][] = [\n 'number' => $object['Phone'],\n 'nationalFormat' => phone_national($this->profile->user->country_code, $object['Phone']),\n 'type' => 'phone',\n ];\n }\n\n if (empty($object['MobilePhone']) === false && $object['MobilePhone']) {\n $record['phoneNumbers'][] = [\n 'number' => $object['MobilePhone'],\n 'nationalFormat' => phone_national(\n $this->profile->user->country_code,\n $object['MobilePhone']\n ),\n 'type' => 'mobile',\n ];\n }\n\n $data[] = $record;\n }\n } catch (NoResultsException $e) {\n $data = [];\n }\n\n return $data;\n });\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n\n if ($crmAccountId === null) {\n return $data;\n }\n\n if ($userId) {\n $profileRepository = app(ProfileRepository::class);\n $profile = $profileRepository->findProfileByUserId($this->config, $userId);\n\n $ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;\n }\n\n try {\n // Perhaps their profile has no opportunity permissions.\n if ($this->profile === null || $this->profile->opportunity_fields === null) {\n return $data;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $query = $queryBuilder->buildFindOpportunitiesQuery();\n\n $objects = $this->queryHandler->query($query, ['accountId' => $crmAccountId]);\n\n foreach ($objects as $object) {\n $record = [\n 'crmId' => $object['Id'],\n 'name' => $object['Name'],\n 'won' => $object['IsWon'],\n 'closed' => $object['IsClosed'],\n ];\n\n $valueFieldName = 'Amount';\n if ($this->config->opportunity_value_field_id) {\n $valueFieldName = $this->config->opportunityValueField->crm_provider_id;\n }\n\n if (empty($object[$valueFieldName]) === false) {\n $currency = $object['CurrencyIsoCode'] ?? $this->config->default_currency;\n $value = formatCurrency($object[$valueFieldName], $currency);\n\n $record += [\n 'value' => $value,\n ];\n }\n\n $stage = $this->config->stages()\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->where('name', $object['StageName'])\n ->first();\n\n // Lazy create the stage.\n if ($stage === null) {\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $object['StageName']);\n }\n\n $record += [\n 'stage' => [\n 'id' => $stage->id_string,\n 'name' => $stage->name,\n ],\n ];\n\n if (empty($object['RecordTypeId']) === false) {\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $object['RecordTypeId'])\n ->first();\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n }\n\n if ($ownerId && isset($object['OwnerId']) && $object['OwnerId'] === $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n } catch (NoResultsException $e) {\n return $data;\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n public function getContactRolesFromCrm(?Carbon $since = null): array\n {\n $roles = [];\n\n if ($this->profile === null) {\n return $roles;\n }\n\n $queryBuilder = app(QueryBuilder::class, ['profile' => $this->profile]);\n\n $query = $queryBuilder->buildGetContactRolesQuery($since);\n\n try {\n $objects = $this->queryHandler->query($query);\n\n foreach ($objects as $object) {\n $roles[] = [\n 'id' => $object['Id'],\n 'contactId' => $object['ContactId'],\n 'opportunityId' => $object['OpportunityId'],\n 'ownerId' => $object['Opportunity']['OwnerId'] ?? null,\n 'isPrimary' => $object['IsPrimary'],\n 'role' => $object['Role'],\n ];\n }\n } catch (NoResultsException) {\n // Just return an empty array.\n $this->logger->info('[Salesforce] No contact roles found', [\n 'since' => $since?->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n }\n\n return $roles;\n }\n\n public function syncContactRoles(Carbon $since): int\n {\n $contactRoleRepository = app(ContactRoleRepository::class);\n\n $crmContactRoles = $this->getContactRolesFromCrm(since: $since);\n $syncCount = 0;\n $contactRoles = [];\n\n foreach ($crmContactRoles as $crmContactRole) {\n $contactRoles[] = $this->importContactRole($crmContactRole);\n $syncCount++;\n }\n\n $contactRoleRepository->saveContactRoles($contactRoles);\n\n $this->syncRemotelyDeletedContactRoles();\n\n return $syncCount;\n }\n\n private function importContactRole(array $contactRole): array\n {\n $contact = $this->config->contacts()\n ->where('crm_provider_id', $contactRole['contactId'])\n ->first();\n\n if ($contact === null) {\n $contact = $this->syncContact($contactRole['contactId']);\n }\n\n $opportunity = $this->config->opportunities()\n ->where('crm_provider_id', $contactRole['opportunityId'])\n ->first();\n\n if ($opportunity === null) {\n $opportunity = $this->syncOpportunity($contactRole['opportunityId']);\n }\n\n $role = null;\n if (! empty($contactRole['role'])) {\n $role = mb_strimwidth($contactRole['role'], 0, 191);\n }\n\n return [\n 'crm_configuration_id' => $this->config->getId(),\n 'contact_id' => $contact->getId(),\n 'crm_provider_id' => $contactRole['id'],\n 'subject_type' => ContactRole::SUBJECT_TYPE_OPPORTUNITY,\n 'subject_id' => $opportunity->getId(),\n 'is_primary' => $contactRole['isPrimary'],\n 'role' => $role,\n ];\n }\n\n protected function syncRemotelyDeletedContactRoles(): bool\n {\n try {\n $deletedRemotely = $this->queryHandler->queryDeleted('OpportunityContactRole');\n } catch (NoResultsException $e) {\n return false;\n }\n\n $deletedOpportunities = $deletedRemotely->getResults();\n $deletedIds = array_column($deletedOpportunities, 'id');\n\n $contactRoleRepository = app(ContactRoleRepository::class);\n\n foreach (array_chunk($deletedIds, self::HARD_DELETE_CHUNK) as $chunk) {\n $contactRoleRepository->deleteContactRoles($chunk);\n\n $this->logger->info('[' . $this->getDisplayName() . '] Remotely deleted opportunities synced', [\n 'teamId' => $this->team->id_string,\n 'remotelyDeletedOpportunities' => $chunk,\n 'count' => count($chunk),\n ]);\n }\n\n return true;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = $objects = [];\n\n $hasWho = \\in_array($objectType, ['lead', 'contact']);\n $playbook = $this->getPlaybook($this->profile->user);\n $fields = array_merge(\n $this->profile->getFieldsAsArray(parent::OBJECT_TASK),\n $playbook && $playbook->activityField ? [$playbook->activityField->crm_provider_id] : []\n );\n\n // Query should default to any open call for that user.\n $query = '\n SELECT ' . implode(',', array_unique($fields)) . '\n FROM Task\n WHERE OwnerId = :ownerId\n AND IsArchived = false\n AND IsDeleted = false\n AND IsClosed = false\n AND (';\n\n if ($objectType === 'account') {\n // This covers tasks tied to a related contact or opportunity too.\n $query .= '\n AccountId = :accountId';\n }\n\n if ($hasWho) {\n $query .= '\n WhoId = :whoId';\n\n // If we are also going to check on a specific opportunity, set that up.\n if ($opportunityId) {\n $query .= ' OR WhatId = :whatId';\n }\n }\n\n $query .= ' ) ORDER BY LastModifiedDate DESC';\n\n try {\n $objects = $this->queryHandler->query($query, [\n 'ownerId' => $this->profile->crm_provider_id,\n 'whoId' => $objectId,\n 'whatId' => $opportunityId,\n 'accountId' => $objectId,\n ]);\n } catch (NoResultsException $e) {\n return $data;\n } finally {\n $this->logger->debug(sprintf('[Salesforce] Found %s tasks for query \"%s\"', count($objects), $query));\n }\n\n foreach ($objects as $object) {\n $dueDate = $object['ActivityDate'] ? Carbon::parse($object['ActivityDate'])->toIso8601String() : null;\n $data[] = [\n 'crmId' => $object['Id'],\n 'subject' => $object['Subject'],\n 'due' => $dueDate,\n 'type' => $object[$playbook->activityField->crm_provider_id],\n ];\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getEvents(string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = $objects = [];\n $user = $this->profile?->user;\n if ($this->profile === null || $user === null) {\n return $data;\n }\n\n $hasWho = \\in_array($objectType, ['lead', 'contact']);\n $playbook = $this->getPlaybook($user);\n $fields = array_merge(\n $this->profile->getFieldsAsArray(parent::OBJECT_EVENT),\n $playbook && $playbook->activityField ? [$playbook->activityField->crm_provider_id] : []\n );\n\n // Query should default to any event starting in the last week and ending up until today owned by the user.\n $query = '\n SELECT ' . implode(',', array_unique($fields)) . '\n FROM Event\n WHERE OwnerId = :ownerId\n AND IsArchived = false\n AND IsAllDayEvent = false\n AND StartDateTime >= LAST_N_DAYS:7\n AND EndDateTime <= TODAY\n AND (';\n\n if ($objectType === 'account') {\n // This covers events tied to a related contact or opportunity too.\n $query .= '\n AccountId = :accountId';\n }\n\n if ($hasWho) {\n $query .= '\n WhoId = :whoId';\n\n // If we are also going to check on a specific opportunity, set that up.\n if ($opportunityId) {\n $query .= ' OR WhatId = :whatId';\n }\n }\n\n $query .= ' ) ORDER BY LastModifiedDate DESC';\n\n try {\n $objects = $this->queryHandler->query($query, [\n 'ownerId' => $this->profile->crm_provider_id,\n 'whoId' => $objectId,\n 'whatId' => $opportunityId,\n 'accountId' => $objectId,\n ]);\n } catch (NoResultsException $e) {\n return $data;\n } finally {\n $this->logger->debug(sprintf('[Salesforce] Found %s tasks for query \"%s\"', count($objects), $query));\n }\n\n foreach ($objects as $object) {\n $dueDate = $object['StartDateTime'] ? Carbon::parse($object['StartDateTime'])->toIso8601String() : null;\n\n $data[] = [\n 'crmId' => $object['Id'],\n 'subject' => $object['Subject'],\n 'due' => $dueDate,\n 'type' => $object[$playbook->activityField->crm_provider_id],\n ];\n }\n\n return $data;\n }\n\n /**\n * Try to find CRM Objects using email address\n *\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchExactlyByEmail(string $email, ?int $userId = null): ?array\n {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $sosl = $queryBuilder->buildMatchByQuery($email, Field::TYPE_EMAIL);\n if ($sosl === null) {\n return null;\n }\n\n try {\n $objects = $this->queryHandler->search($sosl);\n $objects = $this->queryHandler->prioritiseResults(\n $objects,\n $email,\n QueryHandler::PRIORITISE_EMAIL\n );\n\n $data = $this->convertCrmData($objects, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (NoResultsException $e) {\n // Try the account next.\n if ($this->profile->account_fields === null) {\n return null;\n }\n }\n\n return null;\n }\n\n public function getDomain(string $email): ?string\n {\n // SF improved search - strip the domain extension, min domain name length 4\n return $this->getCompanyNameFromEmail(email: $email, minNameLength: 4);\n }\n\n /**\n * Try to find CRM objects using domain name of the email address\n *\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByDomain(string $domain, ?int $userId = null): ?array\n {\n $companyName = $domain;\n\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $sosl = $queryBuilder->buildMatchByDomainQuery($companyName);\n\n try {\n $objects = $this->queryHandler->search($sosl);\n\n $data = $this->convertCrmData($objects, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (NoResultsException) {\n return null;\n }\n }\n\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n // Don't bother looking up numbers that are masked.\n if (str_contains($phone, '**')) {\n return null;\n }\n\n if ($this->isPhoneNumberOfTeamMember($phone)) {\n return null;\n }\n\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $phoneNational = phone_national(null, $phone) ?? '';\n $possiblePhoneFormats = collect([\n preg_replace('/\\D/', '', ltrim($phone, '0+')),\n preg_replace('/\\D/', '', $phoneNational),\n formatDashPhoneNumber($phone),\n $phoneNational,\n ])\n ->filter() // Removes null and empty strings\n ->unique()\n ->values();\n\n foreach ($possiblePhoneFormats as $phone) {\n $sosl = $queryBuilder->buildMatchByQuery($phone, Field::TYPE_PHONE);\n if ($sosl === null) {\n continue;\n }\n\n try {\n $objects = $this->queryHandler->search($sosl);\n $objects = $this->queryHandler->prioritiseResults(\n $objects,\n $phone,\n QueryHandler::PRIORITISE_PHONE\n );\n\n return $this->convertCrmData($objects, $userId);\n } catch (NoResultsException) {\n continue;\n }\n }\n\n return null;\n }\n\n private function isPhoneNumberOfTeamMember(string $phone): bool\n {\n $teamRepository = app(TeamRepository::class);\n $user = $teamRepository->findTeamMemberByPhone($this->team, $phone);\n\n if ($user instanceof User) {\n return true;\n }\n\n return false;\n }\n\n protected function getCacheKey(string $object, ?int $userId = null): ?string\n {\n $key = $this->profile->id . $object;\n $keySuffix = $this->getOwnerKeySuffix($userId);\n\n return $key . $keySuffix;\n }\n\n private function getOwnerKeySuffix(?int $userId = null): string\n {\n return $userId === null ? '' : (string) $userId;\n }\n\n /** Determine the CRM Objects which represent the call activity. */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n if ($this->profile === null) {\n return null;\n }\n\n $cacheKey = $this->getCacheKey($name, $userId);\n\n $result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $sosl = $queryBuilder->buildMatchByQuery($name, 'name');\n if ($sosl === null) {\n return false;\n }\n\n try {\n $objects = $this->queryHandler->search($sosl);\n } catch (NoResultsException $e) {\n return false;\n }\n\n $objects = $this->queryHandler->prioritiseResults(\n $objects,\n $name,\n QueryHandler::PRIORITISE_NAME\n );\n\n $data = $this->convertCrmData($objects, $userId);\n\n return (! empty(array_filter($data))) ? $data : false;\n });\n\n return is_array($result) ? $result : null;\n }\n\n /**\n * @return array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n protected function convertCrmData(QueryIterator $objects, ?int $userId = null): array\n {\n $lead = null;\n $contact = null;\n $opportunity = null;\n $account = null;\n $stage = null;\n $countryCode = null;\n\n if ($objects->count() > 0) {\n $object = $objects->current();\n\n if ($object['attributes']['type'] === 'Lead') {\n $lead = $this->importLead($object);\n\n // Lead might not be imported if the Stage is null for example.\n if ($lead) {\n $countryCode = $lead->country_code;\n $stage = $lead->stage;\n }\n } else {\n if ($object['attributes']['type'] === 'Contact') {\n $contact = $this->importContact($object);\n $account = $contact->account;\n } else {\n $account = $this->importAccount($object);\n }\n\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account) {\n $countryCode = $account->country_code;\n }\n\n try {\n $sfOpportunities = $this->findOpportunities(\n $account?->getCrmProviderId(),\n $contact?->getCrmProviderId(),\n $userId\n );\n\n // Take the first opportunity, which will be ordered as priority based on their settings.\n if (! empty($sfOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($sfOpportunities[0]['crmId']);\n $stage = $opportunity?->stage;\n }\n } catch (Exception) {\n // Nothing to see here.\n }\n }\n }\n\n return [\n $lead,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n /**\n * @inheritdoc\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n if ($stage->type === Stage::TYPE_LEAD) {\n $objectType = 'Lead';\n $objectId = $crmObject->crm_provider_id;\n $objectStageType = 'Status';\n } else {\n $objectType = 'Opportunity';\n $objectId = $crmObject->crm_provider_id;\n $objectStageType = 'StageName';\n }\n\n $headers = [];\n if ($this->config->trigger_assignment_rules === false) {\n // @see: https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/headers_autoassign.htm\n $headers = [\n 'Sforce-Auto-Assign' => 'false',\n ];\n }\n\n $this->updateRecord($objectType, $objectId, [$objectStageType => $stage->name], $headers);\n }\n\n public function parseObjectType(string $objectId): string\n {\n if (Str::startsWith($objectId, '001')) {\n return 'account';\n }\n\n if (Str::startsWith($objectId, '003')) {\n return 'contact';\n }\n\n if (Str::startsWith($objectId, '00Q')) {\n return 'lead';\n }\n\n if (Str::startsWith($objectId, '006')) {\n return 'opportunity';\n }\n\n if (Str::startsWith($objectId, '00U')) {\n return 'event';\n }\n\n if (Str::startsWith($objectId, '00T')) {\n return 'task';\n }\n\n throw new \\InvalidArgumentException('Unsupported Object Type');\n }\n\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, ['profile' => $this->profile]);\n $query = $queryBuilder->buildGetUsersQuery($userToSearch);\n\n try {\n $salesforceUsers = $this->queryHandler->query($query, [\n 'active' => true,\n ]);\n } catch (NoResultsException $e) {\n $this->logger->info('[Salesforce] Sync Profiles. No users found', [\n 'query' => $query,\n 'error' => $e->getMessage(),\n ]);\n\n return null;\n }\n\n $teamRepository = app(TeamRepository::class);\n $customRules = $this->getCustomProfileRules($teamRepository);\n\n foreach ($salesforceUsers as $crmUser) {\n if ($crmUser['Email'] === null) {\n continue;\n }\n\n if (! $this->customProfileValidation($crmUser, $customRules)) {\n continue;\n }\n\n $user = $teamRepository->findActiveTeamMemberByEmail($this->team, $crmUser['Email']);\n\n if (! $user instanceof User) {\n continue;\n }\n\n $edition = $crmUser['UserPreferencesLightningExperiencePreferred']\n ? Profile::EDITION_LIGHTNING\n : Profile::EDITION_CLASSIC;\n\n $profileRepository = app(ProfileRepository::class);\n $profile = $profileRepository->updateOrCreateProfile(\n $user,\n [\n 'crm_configuration_id' => $this->config->getId(),\n 'crm_provider_id' => $crmUser['Id'],\n ],\n [\n 'user_id' => $user->getId(),\n 'edition' => $edition,\n 'has_external_cti' => ! empty($crmUser['CallCenterId']),\n 'crm_profile_id' => $crmUser['ProfileId'],\n ]\n );\n\n if ($userToSearch instanceof User && $userToSearch->getId() === $user->getId()) {\n return $profile;\n }\n }\n\n // Clean up inactive profiles\n try {\n $this->archiveInactiveProfiles();\n } catch (\\Exception $e) {\n $this->logger->warning('[Salesforce] Profile archiving failed', [\n 'teamId' => $this->team->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $url = null;\n\n // For Salesforce it's easy, we just point every object to the apex domain and they handle it.\n switch ($objectType) {\n case 'lead':\n case 'account':\n case 'contact':\n case 'opportunity':\n case 'task':\n case 'event':\n case 'activity':\n\n $url = $this->config->crm_base_url . '/' . $providerId;\n\n break;\n }\n\n return $url;\n }\n\n public function buildTaskSearchFields(): array\n {\n return ['Id', 'WhoId', 'WhatId', 'AccountId'];\n }\n\n public function getTaskByFilterConditions(\n array $fields,\n array $filters,\n bool $bulkSearch = false,\n bool $strictFilters = true\n ): ?array {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $query = $queryBuilder->buildSearchTaskQuery($fields, $filters, $bulkSearch, $strictFilters);\n\n try {\n if (! $bulkSearch) {\n $objects = $this->queryHandler->query($query, $filters);\n if ($objects->count() === 1) {\n return $objects->current();\n }\n }\n\n if ($bulkSearch) {\n $objects = $this->queryHandler->query($query);\n $records = [];\n foreach ($objects as $record) {\n $key = $record[end($fields)];\n $records[$key] = $record;\n }\n\n return $records;\n }\n } catch (\\Exception $e) {\n $this->logger->info('[Salesforce] Failed to execute query', [\n 'query' => $query,\n 'error' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function mapCrmObjects(array $task): array\n {\n $activityData = [];\n\n if (! empty($task['WhoId'])) {\n $type = $this->parseObjectType($task['WhoId']);\n $activityData[$type] = $task['WhoId'];\n }\n if (! empty($task['AccountId'])) {\n $activityData['account'] = $task['AccountId'];\n }\n if (! empty($task['WhatId'])) {\n $activityData['opportunity'] = $task['WhatId'];\n }\n\n return $activityData;\n }\n\n /**\n * Get SF task by Outreach call id.\n */\n public function getTaskByFilter(\n string $activityFieldType,\n array $filters,\n string $operator = '=',\n array $additionalFields = []\n ): ?array {\n $data = [];\n\n try {\n // Default (base) fields.\n $fields = ['Id', 'Subject', 'Description', 'ActivityDate', 'WhoId', 'WhatId', $activityFieldType];\n\n foreach ($additionalFields as $additionalField) {\n $fields[] = $additionalField->crm_provider_id;\n }\n\n $fields = array_unique($fields);\n\n // Find task with the same Outreach id as the call id.\n $query = 'SELECT ' . implode(',', $fields) . '\n FROM Task\n WHERE IsArchived = false AND IsDeleted = false';\n\n foreach ($filters as $key => $value) {\n $key = preg_quote($key, '/');\n $key = str_replace(['\\'', '\"'], '', $key);\n // Prepare the substitution.\n $strKey = \":$key\";\n\n $query .= \" AND $key $operator $strKey\";\n }\n\n $query .= ' ORDER BY LastModifiedDate DESC LIMIT 1';\n\n $objects = $this->queryHandler->query($query, $filters);\n\n // There should be only one task related to this call if any.\n if ($objects->count() === 1) {\n $object = $objects->current();\n\n $dueDate = $object['ActivityDate'] ? Carbon::parse($object['ActivityDate'])->toIso8601String() : null;\n\n $data = array_merge($object, [\n 'crmId' => $object['Id'],\n 'subject' => $object['Subject'],\n 'summary' => $object['Description'],\n 'due' => $dueDate,\n 'Type' => $object[$activityFieldType],\n ]);\n }\n } catch (NoResultsException $e) {\n // Filters don't match any records.\n } catch (ServiceUnavailableException $serviceUnavailableException) {\n // Service cannot be queried. We should probably log this.\n }\n\n return $data;\n }\n\n /**\n * Get Salesforce fields including datetime fields\n *\n * @param $objectType\n */\n private function getAllFieldsAsArray($objectType): array\n {\n $basicFields = [];\n // Not all users have access to all object fields.\n if ($this->profile->{$objectType . '_fields'}) {\n $basicFields = explode(',', $this->profile->{$objectType . '_fields'});\n }\n\n $extraFields = [\n 'CreatedDate',\n 'LastModifiedDate',\n 'IsDeleted',\n ];\n\n if ($objectType === self::OBJECT_OPPORTUNITY\n && $this->config->opportunity_value_field_id\n && ! in_array($this->config->opportunityValueField->crm_provider_id, $basicFields)\n ) {\n $extraFields[] = $this->config->opportunityValueField->crm_provider_id;\n }\n\n return array_unique(array_merge($basicFields, $extraFields));\n }\n\n /**\n * Generate transcription for activity description.\n */\n private function generateTranscription(Activity $activity): string\n {\n if (! ($this->config->store_transcript)) {\n // If sending transcription to activity toggle is disabled\n return '';\n }\n\n return $this->transcriptionService\n ->findTranscriptionByActivity($activity)\n ->map(static function (array $transcriptionSegment): string {\n return $transcriptionSegment['formattedStartsAt'] . ' | ' . $transcriptionSegment['transcript'];\n })\n ->implode(PHP_EOL);\n }\n\n /**\n * Find related Salesforce event based on activity data\n *\n * @return array<string>\n */\n public function fetchRelatedActivity(Activity $activity): array\n {\n $this->logger->info('[Salesforce] Searching for related activity', [\n 'activityId' => $activity->getUuid(),\n 'ownerId' => $this->profile?->crm_provider_id,\n ]);\n\n $sfEvent = $this->fetchRelatedEvent($activity);\n if (empty($sfEvent)) {\n $this->logger->info('[Salesforce] No related activity found', [\n 'activityId' => $activity->getUuid(),\n 'ownerId' => $this->profile?->crm_provider_id,\n 'account' => $activity->hasAccount()\n ? $activity->getAccount()->getCrmProviderId()\n : null,\n ]);\n\n return [];\n }\n\n return $sfEvent;\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n if ($activity->isTypeConference() === false) {\n return null;\n }\n\n if ($activity->hasActualStartTime() === false && $activity->hasScheduledStartTime() === false) {\n return null;\n }\n\n if (! $activity->hasProspect()) {\n $this->logger->info('[Salesforce] Skip look up, Activity not linked to Lead, Contact or Account', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n $playbook = $this->getPlaybook($activity->getUser());\n if ($playbook !== null && $playbook->getActivityType() === Playbook::ACTIVITY_TYPE_TASK) {\n $this->logger->info('[Salesforce] Skip auto-sync for task-based playbook', [\n 'activityUuid' => $activity->getUuid(),\n 'playbookId' => $playbook->getId(),\n 'playbookType' => $playbook->getActivityType(),\n ]);\n\n return null;\n }\n\n try {\n $sfEvent = $this->fetchRelatedActivity($activity);\n if (empty($sfEvent)) {\n return null;\n }\n\n [$activityField, $activityType] = $this->resolveActivityTypeFromEvent($activity, $sfEvent);\n\n $this->logger->info('[Salesforce] Found related activity', [\n 'activityId' => $activity->getUuid(),\n 'sfEvent' => $sfEvent['Id'],\n 'activityFieldName' => $activityField,\n 'crmActivityType' => ($activityField !== null && isset($sfEvent[$activityField]))\n ? $sfEvent[$activityField]\n : null,\n 'activityType' => $activityType,\n ]);\n\n $userId = $this->findRelatedActivityUserId($activity, $sfEvent);\n\n if ($activity->getUserId() !== $userId) {\n $this->logger->info('[Salesforce] Updating meeting owner', [\n 'activityId' => $activity->getUuid(),\n 'oldUserId' => $activity->getUserId(),\n 'newUserId' => $userId,\n ]);\n }\n\n $this->updateSfEventDescription($activity, $sfEvent);\n\n $activity->update([\n 'user_id' => $userId,\n 'crm_provider_id' => $sfEvent['Id'],\n 'playbook_category_id' => $activityType->id ?? $activity->getCategory()?->getId(),\n ]);\n\n $this->logger->info('[Salesforce] Activity updated', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return $activity;\n } catch (\\Exception $exception) {\n \\Sentry::captureException($exception);\n\n throw $exception;\n }\n }\n\n /**\n * @param array<string, mixed> $sfEvent\n *\n * @return array{0: string|null, 1: mixed}\n */\n private function resolveActivityTypeFromEvent(Activity $activity, array $sfEvent): array\n {\n $activityField = $this->getActivityFieldName($activity);\n $activityType = null;\n\n if ($activityField !== null && ! empty($sfEvent[$activityField])) {\n $playbook = $this->getPlaybook($activity->getUser());\n $activityType = $this->getPlaybookCategory($playbook, strval($sfEvent[$activityField]));\n }\n\n return [$activityField, $activityType];\n }\n\n /**\n * @param array<string> $sfEvent\n */\n private function findRelatedActivityUserId(Activity $activity, array $sfEvent): int\n {\n $userId = $activity->getUserId();\n\n if (empty($sfEvent['OwnerId']) === false) {\n $profile = $this\n ->config\n ->profiles()\n ->where('crm_provider_id', $sfEvent['OwnerId'])\n ->get()\n ->filter(static function (Profile $profile) use ($activity): bool {\n if (! $activity->isTypeConference()) {\n return ! empty($profile->user) ? $profile->user->isStatusActive() : false;\n }\n\n $participants = $activity->getParticipants();\n\n return ! empty($profile->user)\n ? $profile->user->isStatusActive()\n && $profile->user->hasPermission(PermissionEnum::RECORD_MEETING)\n && $participants->contains('user_id', $profile->user_id)\n : false;\n })\n ->first();\n\n if ($profile) {\n $userId = $profile->user_id;\n }\n }\n\n return $userId;\n }\n\n /**\n * @param array<string, mixed> $sfEvent\n */\n private function updateSfEventDescription(Activity $activity, array $sfEvent): void\n {\n try {\n if (str_contains($sfEvent['Description'], $activity->id_string)) {\n return;\n }\n\n $payload = [\n 'Description' => $sfEvent['Description']\n . PHP_EOL\n . PHP_EOL\n . (new DecorateActivity())->generateDescription($activity),\n ];\n\n $this->logger->info('[Salesforce] Update record', [\n 'activityId' => $activity->getUuid(),\n 'sfEvent' => $sfEvent['Id'],\n 'payload' => $payload,\n ]);\n\n $payload = array_merge(\n $payload,\n $this->payloadBuilder->fetchCustomFieldData($activity, Field::OBJECT_EVENT)\n );\n\n $this->updateRecord('Event', $sfEvent['Id'], $payload);\n } catch (\\Exception) {\n $this->logger->error('[Salesforce] Failed to update record', [\n 'activityUuid' => $activity->getUuid(),\n 'sfEvent' => $sfEvent['Id'],\n ]);\n }\n }\n\n /**\n * Returns the most recently modified Event within time range (if any).\n *\n * @return array|null An Event record from Salesforce.\n */\n private function fetchRelatedEvent(Activity $activity): ?array\n {\n $ownerId = $this->profile?->crm_provider_id;\n if ($ownerId === null) {\n return [];\n }\n\n /** @var ?Carbon $from */\n /** @var ?Carbon $to */\n [$from, $to] = $this->getFromToDates($activity);\n\n try {\n $whoId = null;\n $hasWho = $activity->lead_id || $activity->contact_id;\n if ($hasWho) {\n $whoId = $activity->hasLead()\n ? $activity->getLead()->crm_provider_id\n : $activity->getContact()->crm_provider_id;\n }\n\n if ($hasWho === false && $activity->account_id === null) {\n return null;\n }\n\n $query = $this->buildFetchRelatedEventQuery($activity);\n\n $objects = $this->queryHandler->query($query, [\n 'ownerId' => $ownerId,\n 'whoId' => $whoId,\n 'whatId' => $activity->hasOpportunity() ? $activity->getOpportunity()->crm_provider_id : null,\n 'accountId' => $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null,\n 'from' => $from?->format('Y-m-d\\TH:i:s\\Z'),\n 'to' => $to?->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($objects as $object) {\n return $object;\n }\n } catch (NoResultsException $e) {\n return [];\n }\n\n return [];\n }\n\n private function getFromToDates(Activity $activity): array\n {\n $from = null;\n $to = null;\n\n /** @var ?CalendarEvent $calendarEvent */\n $calendarEvent = $activity->calendarEvent()->first();\n if ($calendarEvent !== null) {\n $from = $calendarEvent->getStartTime();\n $to = $calendarEvent->getEndTime();\n }\n\n // For non-calendar imported activities\n // Also double check if calendar event dates could be null?\n // If null use what we've got so far\n if ($from === null || $to === null) {\n $from = $activity->hasScheduledStartTime()\n ? $activity->getScheduledStartTime()\n : $activity->getActualStartTime();\n $to = $activity->hasScheduledEndTime()\n ? $activity->getScheduledEndTime()->addMinutes(15)\n : $activity->getActualEndTime();\n }\n\n return [$from, $to];\n }\n\n /**\n * Determines the appropriate activity field name for querying Salesforce events.\n *\n * This method follows a hierarchy to determine the field name:\n * 1. Uses the playbook's activity field if it exists and is in the profile's accessible fields\n * 2. Falls back to the default activity field if the profile has no event fields configured\n * 3. Returns null if no suitable field is found\n *\n * @param Activity $activity The activity to determine the field for\n *\n * @return string|null The field name to use in queries, or null if none is available\n */\n private function getActivityFieldName(Activity $activity): ?string\n {\n if ($this->profile === null) {\n $this->logger->warning('[Salesforce] Cannot determine activity field - profile not found', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n $profileEventFields = $this->profile->getFieldsAsArray('event');\n\n if (empty($profileEventFields)) {\n $defaultActivityField = $this->getDefaultActivityField(Field::OBJECT_EVENT);\n $defaultFieldName = $defaultActivityField?->getAttribute('crm_provider_id');\n // Profile not yet synced — fall back to the default activity field.\n // There is a small chance that the profile won't have Default Activity Type field access\n // in which case the query will fail.\n // This is however an edge case and should be reviewed for profile sync issues.\n Sentry::withScope(function (\\Sentry\\State\\Scope $scope) use ($defaultFieldName): void {\n $scope->setContext('details', [\n 'profileId' => $this->profile->id,\n 'defaultField' => $defaultFieldName,\n ]);\n Sentry::captureMessage(\n '[Salesforce] Profile event fields empty, falling back to default activity field.',\n \\Sentry\\Severity::warning()\n );\n });\n\n return $defaultFieldName;\n }\n\n $playbook = $this->getPlaybook($activity->getUser());\n\n if (! is_null($playbook) && ! is_null($playbook->getActivityField())) {\n $playbookFieldName = $playbook->getActivityField()->getAttribute('crm_provider_id');\n\n if (in_array($playbookFieldName, $profileEventFields, true)) {\n return $playbookFieldName;\n }\n\n $this->logger->warning('[Salesforce] Playbook activity field not found in profile fields', [\n 'activityId' => $activity->getUuid(),\n 'playbookField' => $playbookFieldName,\n 'profileId' => $this->profile->id,\n ]);\n }\n\n return null;\n }\n\n private function buildFetchRelatedEventQuery(Activity $activity): string\n {\n $hasWho = $activity->lead_id || $activity->contact_id;\n\n $activityFieldName = $this->getActivityFieldName($activity);\n $fields = array_filter(['Id', 'Description', 'OwnerId', $activityFieldName]);\n\n $ownerCondition = '(OwnerId = :ownerId OR CreatedById = :ownerId)';\n\n $query = '\n SELECT ' . implode(',', $fields) . '\n FROM Event\n WHERE ' . $ownerCondition . '\n AND IsArchived = false\n AND IsAllDayEvent = false\n AND StartDateTime >= :from\n AND EndDateTime <= :to\n AND (';\n\n $operator = '';\n if ($activity->account_id) {\n // This covers events tied to a related contact or opportunity too.\n $query .= 'AccountId = :accountId';\n\n $operator = ' OR ';\n }\n\n if ($hasWho) {\n $query .= $operator . 'WhoId = :whoId';\n\n // If we are also going to check on a specific opportunity, set that up.\n if ($activity->opportunity_id) {\n $query .= ' OR WhatId = :whatId';\n }\n }\n\n $query .= ') ORDER BY LastModifiedDate DESC';\n\n return $query;\n }\n\n public function fetchProspect(array $task): array\n {\n $lead = $account = $opportunity = $contact = $stage = $countryCode = null;\n $externalId = $task['WhoId'] ?? null;\n\n // Lead or Contact\n if ($externalId) {\n try {\n [$lead, $account, $opportunity, $contact, $stage, $countryCode] = $this->parseRecords($externalId);\n } catch (\\InvalidArgumentException $exception) {\n // Invalid object type.\n }\n }\n\n // If we happen to know the opportunity or account from the Task, figure that out.\n if (empty($task['WhatId']) === false) {\n // WhatId could be either Account ID or Opportunity ID.\n // If WhatId is Opportunity ID, get the opportunity and stage from the CRM.\n try {\n [, $account, $opportunity, , $stage, ] = $this->parseRecords($task['WhatId']);\n } catch (\\InvalidArgumentException $exception) {\n // Invalid object type.\n }\n }\n\n return [$lead, $account, $opportunity, $contact, $stage, $countryCode];\n }\n\n /**\n * Save activity transcription summary as note\n */\n public function saveTranscriptionSummaryAsNote(\n ActivityContract $activity,\n string $title,\n string $body,\n ?string $objectId,\n ?NoteObject $noteObject = null,\n ): ?string {\n return $this->saveNote($title, $body, (string) $objectId);\n }\n\n public function getObjectByFilterConditions(string $objectType, array $fields, array $filters): ?array\n {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $query = $queryBuilder->buildObjectSearchQuery($objectType, $fields, $filters);\n\n try {\n $objects = $this->queryHandler->query($query, $filters);\n if ($objects->count() === 1) {\n return $objects->current();\n }\n } catch (\\Exception $e) {\n $this->logger->info('[Salesforce] Failed to execute query', [\n 'query' => $query,\n 'error' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n private function getCustomProfileRules(TeamRepository $teamRepository): array\n {\n $teamSettings = $teamRepository->getTeamSetting($this->team, 'custom_profile_validation');\n\n if ($teamSettings instanceof TeamSettings && $teamSettings->getValueType() === 'array') {\n $customRules = json_decode($teamSettings->getValue(), true);\n if (is_array($customRules)) {\n return $customRules;\n }\n }\n\n return [];\n }\n\n private function customProfileValidation(array $crmUser, array $customRules): bool\n {\n foreach ($customRules as $customRule) {\n if ($crmUser[$customRule['field']] !== $customRule['value']) {\n return false;\n }\n }\n\n return true;\n }\n\n /**\n * When syncing Contact / Lead / Account / Opportunity / Stage crm entities,\n * validate and restore locally trashed objects,\n * before updating them. Objects are identified by CrmProviderId\n */\n private function restoreAnyTrashedEntity(HasMany $targetEntity, string $crmProviderId): void\n {\n $recordExists = $targetEntity->withTrashed()->where(['crm_provider_id' => $crmProviderId])->first();\n if ($recordExists && $recordExists->trashed()) {\n $recordExists->restore();\n }\n }\n\n #[\\Override] public function supportsNotes(): bool\n {\n return true;\n }\n\n private function getOwnerProfile(?string $ownerId): ?Profile\n {\n if ($ownerId === null) {\n return null;\n }\n\n return $this->config->profiles()\n ->where('crm_provider_id', $ownerId)\n ->first();\n }\n}","depth":2,"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Salesforce;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse Illuminate\\Database\\Eloquent\\Relations\\HasMany;\nuse Illuminate\\Events\\Dispatcher;\nuse Illuminate\\Support\\Facades\\Cache;\nuse Illuminate\\Support\\Facades\\Log;\nuse Illuminate\\Support\\Str;\nuse Jiminny\\Component\\Country\\CountriesMap;\nuse Jiminny\\Contracts\\Acl\\PermissionEnum;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\FetchRelatedActivityInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\ImportsBusinessProcessesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\SalesforceBatchSyncInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\SalesforceInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteNoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SearchTaskInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SendSummaryToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SupportsObjectTypeParseInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmProfileRecordTypesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Enums\\CrmObject;\nuse Jiminny\\Events\\Activities\\Crm\\LeadConverted;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpBadRequestException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Exceptions\\NoResultsException;\nuse Jiminny\\Exceptions\\ServiceUnavailableException;\nuse Jiminny\\Jobs\\Crm\\NoteObject;\nuse Jiminny\\Jobs\\Crm\\MatchActivitiesToNewOpportunity;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Calendar\\CalendarEvent;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Contracts\\ActivityContract;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Configuration;\nuse Jiminny\\Models\\Crm\\ContactRole;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Crm\\RecordType;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\TeamSettings;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\ContactRoleRepository;\nuse Jiminny\\Repositories\\Crm\\FieldRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Repositories\\Crm\\RecordTypeFieldValuesRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\Helpers\\ArrayIterator;\nuse Jiminny\\Services\\Crm\\MatchDomainByEmailInterface;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Services\\Crm\\ResolveCompanyNameByEmailTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\Fields\\FieldHelper;\nuse Jiminny\\Services\\Crm\\Salesforce\\Fields\\FieldTypeConverter;\nuse Jiminny\\Services\\Crm\\Salesforce\\Fields\\ValueNormalizer;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\FollowupActivityTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\LogActivityTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\RecordManipulationsTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\SyncFieldsTrait;\nuse Jiminny\\Utils\\CurrencyFormatter;\nuse Jiminny\\Utils\\StringUtil;\nuse Ramsey\\Uuid\\Uuid;\nuse Sentry\\Laravel\\Facade as Sentry;\n\nclass Service extends BaseService implements\n SalesforceInterface,\n SalesforceBatchSyncInterface,\n SyncCrmEntitiesInterface,\n SyncCrmProfileRecordTypesInterface,\n ImportsBusinessProcessesInterface,\n RemoteEntityManipulationInterface,\n FetchRelatedActivityInterface,\n SendSummaryToCrmInterface,\n MatchDomainByEmailInterface,\n SearchTaskInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n SupportsObjectTypeParseInterface,\n RemoteNoteEntityManipulationInterface,\n VerifyTaskExistsInterface\n{\n use ResolveCompanyNameByEmailTrait;\n use SyncFieldsTrait;\n use DeleteObjectsTrait;\n use RecordManipulationsTrait;\n use ServiceTraits\\BatchSyncTrait;\n use FollowupActivityTrait;\n use LogActivityTrait;\n\n /**\n * Note Body Limit for the Old Note-Taking Tool\n *\n * @var int\n */\n private const int CLASSIC_NOTE_MAX_LENGTH = 32000;\n\n /**\n * Note Content Limit for the New Notes\n *\n * @var int\n */\n private const int ENHANCED_NOTE_MAX_LENGTH = 50000000;\n\n private const string INSTALLED_PACKAGE_ID = '033Tw0000007bKbIAI';\n\n private const int CACHE_TTL = 600;\n\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day - 86400\n\n /**\n * @var Client\n */\n protected $client;\n\n protected PayloadBuilder $payloadBuilder;\n protected QueryHandler $queryHandler;\n\n private OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n\n public function __construct(\n Client $client,\n PayloadBuilder $payloadBuilder,\n protected Dispatcher $eventDispatcher,\n private readonly CountriesMap $countriesMap,\n private readonly ProspectPhotoPathService $prospectPhotoPathService,\n ) {\n parent::__construct();\n\n $this->client = $client;\n $this->payloadBuilder = $payloadBuilder;\n $this->queryHandler = app(QueryHandler::class, [\n 'client' => $this->client,\n 'logger' => $this->logger,\n ]);\n $this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n }\n\n public function getDisplayName(): string\n {\n return 'Salesforce';\n }\n\n public function getJobDelay(): int\n {\n return 1;\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n return $user->getSocialAccount(SocialAccount::PROVIDER_SALESFORCE);\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($activity, $crmProviderId) {\n $playbook = $this->getPlaybookFromActivity($activity);\n\n if ($playbook === null) {\n $this->logger->warning('[Salesforce] Cannot verify task - no playbook found', [\n 'activity' => $activity->getId(),\n 'crm_provider_id' => $crmProviderId,\n ]);\n\n return false;\n }\n\n $objectType = $playbook->getActivityType() === Playbook::ACTIVITY_TYPE_EVENT ? 'Event' : 'Task';\n\n try {\n $record = $this->getRecord($objectType, $crmProviderId, ['Id', 'IsDeleted']);\n\n return ! empty($record) && ($record['IsDeleted'] ?? false) === false;\n } catch (HttpNotFoundException|HttpBadRequestException) {\n $this->logger->info('[Salesforce] Activity record not found during verification', [\n 'activity' => $activity->getId(),\n 'object_type' => $objectType,\n 'crm_provider_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n });\n }\n\n public function query(string $queryToRun, array $parameters = []): QueryIterator\n {\n // Due to poorly designed external calls, this method cannot be entirely removed\n return $this->queryHandler->query($queryToRun, $parameters);\n }\n\n /*=========== Organization Information ===============*/\n\n /**\n * Get a list of all the API Versions for the instance.\n *\n * @throws CrmException\n *\n * @return mixed\n *\n */\n public function getApiVersions()\n {\n $url = $this->config->crm_base_url . '/services/data';\n\n $response = $this->client->get($url);\n\n return json_decode($response->getBody(), true);\n }\n\n /**\n * Gets the valid recordTypes for a given Salesforce Object via the describe API.\n */\n private function getRecordTypes(string $crmObject): array\n {\n $url = $this->client->getObjectsUrl() . $crmObject . '/describe';\n\n $response = $this->client->get($url);\n $jsonResponse = json_decode($response->getBody(), true);\n\n $fields = [];\n foreach ($jsonResponse['recordTypeInfos'] as $row) {\n $fields[] = ['recordTypeId' => $row['recordTypeId'], 'default' => $row['defaultRecordTypeMapping']];\n }\n\n return $fields;\n }\n\n /**\n * Convert raw field data into a format compatible with CRM APIs.\n */\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return ValueNormalizer::normalize($fieldType, $fieldValue, $internal);\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n $defaultFields = ($activityType === Playbook::ACTIVITY_TYPE_TASK)\n ? FieldDefinitions::defaultTaskFields()\n : FieldDefinitions::defaultEventFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n // Setup the activity field as the default Type.\n /** @var Field $activityField */\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'Type',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK, Playbook::ACTIVITY_TYPE_EVENT];\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n\n $fieldFilter = ($activityType === Playbook::ACTIVITY_TYPE_TASK)\n ? FieldDefinitions::taskFollowupFieldsFilter()\n : FieldDefinitions::eventFollowupFieldsFilter();\n\n foreach ($fieldFilter as $eachFilter) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $eachFilter);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n /**\n * This one is now called only when ImportActivityTypes is triggered or SyncFieldMetadata executed manually\n * Regular sync now uses SharedSyncFieldsTrait -> syncSingleObjectType\n * Needs to be replaced later on\n */\n public function syncField(Field $field): void\n {\n try {\n if (FieldHelper::isCustomField($field)) {\n $query = '\n SELECT\n Id, Metadata, TableEnumOrId\n FROM\n CustomField\n WHERE\n DeveloperName = :fieldName\n AND\n TableEnumOrId = :fieldType\n AND\n NamespacePrefix = :namespacePrefix';\n\n // We need to constrain the field lookup to the object, in case it's used in multiple places.\n $objectType = \\in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true)\n ? 'activity'\n : $field->object_type;\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'fieldName' => substr($field->crm_provider_id, 0, -\\strlen('__c')),\n 'fieldType' => ucfirst($objectType),\n\n // This is used to ensure we only consider the field within the org, not installed packages.\n 'namespacePrefix' => 'null',\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n // Sync field metadata.\n $metadata = $sfField['Metadata'];\n\n $field->description = mb_strimwidth($metadata['description'] ?? '', 0, 191);\n $field->label = mb_strimwidth($metadata['label'] ?? '', 0, Field::LABEL_MAX_LENGTH);\n $field->type = $this->convertFieldType($metadata['type'], $field->getEntityName());\n $field->is_mandatory = ($metadata['required'] === true);\n $field->length = $metadata['length'];\n $field->default_value = mb_strimwidth(trim($metadata['defaultValue'] ?? '', '\"'), 0, 191);\n $field->save();\n } else {\n $query = '\n SELECT\n Id, DataType, DeveloperName, Label, Length, Description\n FROM\n FieldDefinition\n WHERE\n DurableId = :entityName';\n\n $entityName = $field->getEntityName();\n $sfFields = $this->queryHandler->metadata($query, [\n 'entityName' => $entityName,\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n $convertedType = $this->convertFieldType($sfField['DataType'], $entityName);\n $label = mb_strimwidth($sfField['Label'], 0, Field::LABEL_MAX_LENGTH);\n\n if ($field->isBusinessType()) {\n $label = 'Opportunity Type';\n }\n\n $field->description = mb_strimwidth($sfField['Description'], 0, Field::DESCRIPTION_MAX_LENGTH);\n $field->label = $label;\n $field->type = $convertedType;\n $field->length = $sfField['Length'];\n $field->save();\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n private function convertFieldType(string $from, ?string $entityName = null): string\n {\n $converter = new FieldTypeConverter();\n\n return $converter->convert($from, $entityName);\n }\n\n /**\n * @inheritdoc\n */\n public function importPicklistValues(Field $field): array\n {\n $values = [];\n $fieldValues = [];\n\n try {\n if (FieldHelper::isCustomField($field)) {\n $query = '\n SELECT\n Id, Metadata, TableEnumOrId\n FROM\n CustomField\n WHERE\n DeveloperName = :fieldName\n AND\n TableEnumOrId = :fieldType\n AND\n NamespacePrefix = :namespacePrefix';\n\n // We need to constrain the field lookup to the object, in case it's used in multiple places.\n $objectType = \\in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true) ?\n 'activity' : $field->object_type;\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'fieldName' => substr($field->crm_provider_id, 0, -\\strlen('__c')),\n 'fieldType' => ucfirst($objectType),\n // This is used to ensure we only consider the field within the org, not installed packages.\n 'namespacePrefix' => 'null',\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n $valueSet = $sfField['Metadata']['valueSet'];\n\n if ($valueSet['valueSetName'] === null) {\n // Local picklist values can be obtained easily.\n $picklistValues = $valueSet['valueSetDefinition']['value'];\n } else {\n // But for some fields, we just get the Global Value Picklist pointer so need to do more work.\n $picklistValues = $this->importGlobalValuePicklistValues($valueSet['valueSetName']);\n }\n\n // Import all active values.\n foreach ($picklistValues as $i => $sfFieldValue) {\n // Setup default value.\n if ($sfFieldValue['default']) {\n $field->update(['default_value' => $sfFieldValue['valueName']]);\n }\n\n // This comes through as null if active (lol).\n if ($sfFieldValue['isActive'] !== false) {\n $values[] = [\n 'value' => $sfFieldValue['valueName'],\n 'label' => $sfFieldValue['valueName'],\n 'sequence' => $i,\n 'is_default' => $sfFieldValue['default'],\n ];\n }\n }\n } else {\n $objectFields = $this->getObjectFields($field->object_type);\n $fieldId = $field->crm_provider_id;\n\n // Only work with our field of interest.\n $objectField = array_filter($objectFields, function ($item) use ($fieldId) {\n return $item['name'] === $fieldId;\n });\n\n $objectField = array_shift($objectField);\n if (empty($objectField['picklistValues']) === false) {\n foreach ($objectField['picklistValues'] as $i => $sfFieldValue) {\n // Skip inactive values.\n if ($sfFieldValue['active'] === false) {\n continue;\n }\n\n // Setup default value.\n if ($sfFieldValue['defaultValue']) {\n $field->update(['default_value' => $sfFieldValue['value']]);\n }\n\n $values[] = [\n 'value' => $sfFieldValue['value'],\n 'label' => $sfFieldValue['label'],\n 'sequence' => $i,\n 'is_default' => $sfFieldValue['defaultValue'],\n ];\n }\n }\n }\n\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n $value['value'] = substr($value['value'] ?? '', 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n if (($key = array_search($value['value'], $fieldsToPurge, true)) !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n // Get IDs of the values to be deleted\n $valuesToDelete = $field->values()->whereIn('value', $fieldsToPurge);\n $valuesToDeleteIds = $valuesToDelete->pluck('id');\n if (! $valuesToDeleteIds->isEmpty()) {\n $recordTypeFieldValuesRepository = app(RecordTypeFieldValuesRepository::class);\n $recordTypeFieldValuesRepository->deleteForCrmFieldValueIds($valuesToDeleteIds->toArray());\n\n // Now safely delete from crm_field_values\n $valuesToDelete->delete();\n }\n\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n return $fieldValues;\n }\n\n /**\n * Gets values from Global Value Picklists.\n */\n private function importGlobalValuePicklistValues(string $picklistName): array\n {\n $query = '\n SELECT\n Metadata\n FROM\n GlobalValueSet\n WHERE\n DeveloperName = :picklistName\n LIMIT 1';\n\n try {\n $sfValues = $this->queryHandler->metadata($query, [\n 'picklistName' => $picklistName,\n ]);\n\n // There is always 1 result at this point.\n $sfValue = $sfValues->current();\n\n return $sfValue['Metadata']['customValue'];\n } catch (NoResultsException $noResultsException) {\n // Nothing returned.\n\n return [];\n }\n }\n\n /**\n * @inheritdoc\n */\n public function syncProfileRecordTypes(): void\n {\n $objectTypes = [\n 'lead',\n 'account',\n 'contact',\n 'opportunity',\n 'task',\n 'event',\n ];\n\n foreach ($objectTypes as $objectType) {\n try {\n $crmRecordTypes = $this->getRecordTypes(ucfirst($objectType));\n\n foreach ($crmRecordTypes as $crmRecordType) {\n // If the record type is default and not the Master type, set this.\n if ($crmRecordType['default'] && $crmRecordType['recordTypeId'] !== '012000000000000AAA') {\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $crmRecordType['recordTypeId'])\n ->first();\n\n if ($recordType) {\n $this->profile->{$objectType . '_record_type_id'} = $recordType->id;\n }\n }\n }\n } catch (HttpNotFoundException $exception) {\n Log::error('No access to ' . $objectType . ' object, skipping...');\n\n // XXX: should we log this fact somewhere?\n continue;\n }\n }\n\n if ($this->profile->isDirty()) {\n $this->profile->save();\n }\n }\n\n /**\n * Gets business processes.\n */\n public function importBusinessProcesses(): void\n {\n $query = '\n SELECT\n Id, IsActive, Name, TableEnumOrId\n FROM\n BusinessProcess\n WHERE\n TableEnumOrId IN (\\'Lead\\',\\'Opportunity\\')';\n\n try {\n $sfProcesses = $this->queryHandler->query($query);\n\n // Upsert all processes for the team.\n foreach ($sfProcesses as $sfProcess) {\n /** @var BusinessProcess $businessProcess */\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $sfProcess['Id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => $sfProcess['Name'],\n 'type' => $sfProcess['TableEnumOrId'] === 'Lead' ? 'lead' : 'opportunity',\n 'is_selectable' => $sfProcess['IsActive'],\n ]);\n\n $this->importBusinessProcessStages($businessProcess);\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n /**\n * Gets business process stages.\n */\n private function importBusinessProcessStages(BusinessProcess $businessProcess): void\n {\n $query = '\n SELECT\n Metadata\n FROM\n BusinessProcess\n WHERE\n Id = :processId';\n\n try {\n $stages = [];\n $sfProcessStages = $this->queryHandler->metadata($query, [\n 'processId' => $businessProcess->crm_provider_id,\n ]);\n\n // There is always 1 result at this point.\n $sfProcessStage = $sfProcessStages->current();\n\n // Upsert all processes for the team.\n foreach ($sfProcessStage['Metadata']['values'] as $sfProcessStage) {\n $sanitizedName = urldecode($sfProcessStage['valueName']); // Must decode: \"%2C\" becomes \",\" etc.\n\n $stage = $businessProcess->crm->stages()\n // This MUST match on label because this API doesn't use API Name.\n ->where('label', $sanitizedName)\n ->where('type', $businessProcess->type)\n ->where('is_selectable', 1)\n ->first();\n\n if ($stage) {\n $stages[] = $stage->id;\n }\n }\n\n $businessProcess->stages()->sync($stages);\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n /**\n * Gets record types.\n */\n public function importRecordTypes(): void\n {\n $query = '\n SELECT\n Id, IsActive, Name, BusinessProcessId, SobjectType\n FROM\n RecordType';\n\n try {\n $sfRecordTypes = $this->queryHandler->query($query);\n\n // Upsert all record types for the process.\n foreach ($sfRecordTypes as $sfRecordType) {\n $businessProcess = null;\n if ($sfRecordType['BusinessProcessId']) {\n $businessProcess = $this->config->businessProcesses()\n ->where('crm_provider_id', $sfRecordType['BusinessProcessId'])\n ->first();\n }\n\n /** @var RecordType $recordType */\n $recordType = $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $sfRecordType['Id'],\n ], [\n 'team_id' => $this->team->id,\n 'type' => mb_strtolower($sfRecordType['SobjectType']),\n 'name' => $sfRecordType['Name'],\n 'is_selectable' => $sfRecordType['IsActive'],\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n $this->importRecordTypeFieldValues($recordType);\n }\n } catch (NoResultsException $noResultsException) {\n // Do nothing.\n }\n }\n\n /**\n * Import record type - field value mappings. This only works for standard fields.\n */\n private function importRecordTypeFieldValues(RecordType $recordType): void\n {\n try {\n $query = '\n SELECT\n Metadata\n FROM\n RecordType\n WHERE\n Id = :recordTypeId';\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'recordTypeId' => $recordType->crm_provider_id,\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n // Sync field metadata.\n $picklists = $sfField['Metadata']['picklistValues'];\n\n foreach ($picklists as $picklist) {\n $field = $this->config->fields()->where([\n 'type' => Field::TYPE_PICKLIST,\n 'object_type' => $recordType->type,\n 'crm_provider_id' => $picklist['picklist'],\n ])->first();\n\n if ($field) {\n $fieldValues = [];\n\n foreach ($picklist['values'] as $value) {\n // Must decode: \"%2C\" becomes \",\" etc.\n $fieldValue = $field->values()\n ->where('value', urldecode($value['valueName']))\n ->first();\n\n if ($fieldValue) {\n $fieldValues[] = $fieldValue->id;\n }\n }\n\n $recordType->fieldValues()->sync($fieldValues);\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n /**\n * @inheritdoc\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $params = [];\n $missingStage = null;\n if ($types === null) {\n $types = [Stage::TYPE_LEAD, Stage::TYPE_OPPORTUNITY];\n }\n\n foreach ($types as $type) {\n if ($type === Stage::TYPE_LEAD) {\n $query = '\n SELECT\n Id, ApiName, MasterLabel, SortOrder\n FROM\n LeadStatus';\n } else {\n $query = '\n SELECT\n Id, ApiName, MasterLabel, IsActive, SortOrder, DefaultProbability\n FROM\n OpportunityStage';\n }\n\n if ($missingStageName) {\n $escapedStageName = ValueNormalizer::replaceQueryWithStringLiterals($missingStageName);\n\n $query .= ' WHERE ApiName = :stageName';\n\n $params = [\n 'stageName' => $escapedStageName,\n ];\n }\n\n try {\n $sfStages = $this->queryHandler->query($query, $params);\n } catch (NoResultsException $exception) {\n $sfStages = [];\n }\n\n $missingStage = null;\n\n // Upsert all stages for the team.\n foreach ($sfStages as $sfStage) {\n $selectable = true;\n if (array_key_exists('IsActive', $sfStage)) {\n $selectable = $sfStage['IsActive'];\n }\n\n $this->restoreAnyTrashedEntity($this->config->stages(), $sfStage['Id']);\n\n $stage = $this->config->stages()->updateOrCreate([\n 'crm_provider_id' => $sfStage['Id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($sfStage['ApiName'], 0, 50),\n 'label' => mb_strimwidth($sfStage['MasterLabel'], 0, 191),\n 'type' => $type,\n 'sequence' => $sfStage['SortOrder'] ?? 0,\n 'is_selectable' => $selectable,\n 'probability' => $sfStage['DefaultProbability'] ?? null,\n ]);\n\n if ($missingStageName && $missingStageName === $sfStage['ApiName']) {\n $missingStage = $stage;\n }\n }\n\n if ($missingStageName && $missingStage === null) {\n // If they requested a stage that still doesn't exist, it must be inactive so lazy create it.\n $missingStage = $this->config->stages()->create([\n 'crm_provider_id' => Uuid::uuid4(),\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($missingStageName, 0, 50),\n 'label' => mb_strimwidth($missingStageName, 0, 191),\n 'type' => $type,\n 'sequence' => 0,\n 'is_selectable' => 0,\n ]);\n }\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int\n {\n $syncCount = 0;\n $fields = $this->getAllFieldsAsArray('lead');\n if (\\in_array('Id', $fields, true) === false) {\n return $syncCount;\n }\n\n $query = '\n SELECT ' . rtrim(implode(',', $fields), ',') . '\n FROM Lead\n WHERE LastModifiedDate > :since\n ORDER BY LastModifiedDate ASC';\n\n try {\n $sfLeads = $this->queryHandler->query($query, [\n 'since' => $since->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($sfLeads as $sfLead) {\n // Only sync if previously imported.\n if ($this->hasLead($sfLead['Id'])) {\n $this->importLead($sfLead);\n $syncCount++;\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::LEAD);\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLead(string $crmId): ?Lead\n {\n $fields = $this->getAllFieldsAsArray('lead');\n\n $sfLead = $this->getRecord('Lead', $crmId, $fields);\n\n return $this->importLead($sfLead);\n }\n\n private function importLead($crmData): ?Lead\n {\n /** @var ?Stage $stage */\n $stage = null;\n if (isset($crmData['Status'])) {\n // Get the current stage.\n $stage = $this->config\n ->stages()\n ->where('name', $crmData['Status'])\n ->where('type', Stage::TYPE_LEAD)\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_LEAD], $crmData['Status']);\n }\n }\n\n // If we have no way of importing this, just return null :(\n if ($stage === null) {\n return null;\n }\n\n $countryCode = $crmData['CountryCode'] ?? null;\n\n // Salesforce allows custom \"countries\" to be created. Disregard these.\n if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {\n $countryCode = null;\n }\n\n // If we have no country code, try to parse it from the country name.\n if ($countryCode === null && empty($crmData['Country']) !== false) {\n $countryCode = $this->convertCountryNameToCode($crmData['Country']);\n }\n\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($crmData['Phone'] ?? '', 0, 25);\n $parsedNumber = parsePhoneNumber($countryCode, $number);\n\n $mobilePhone = null;\n if (empty($crmData['MobilePhone']) === false) {\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($crmData['MobilePhone'], 0, 25);\n $mobilePhone = phone_e164($countryCode, $number);\n }\n\n $convertedDate = null;\n $convertedAccount = null;\n $convertedOpportunity = null;\n $convertedContact = null;\n\n if ($crmData['IsConverted'] == 'true') {\n $convertedDate = $crmData['ConvertedDate'];\n\n if (empty($crmData['ConvertedAccountId']) === false) {\n $convertedAccount = $this->config\n ->accounts()\n ->where('crm_provider_id', $crmData['ConvertedAccountId'])\n ->first();\n\n if ($convertedAccount === null) {\n try {\n $convertedAccount = $this->syncAccount($crmData['ConvertedAccountId']);\n } catch (HttpNotFoundException $exception) {\n // Probably the user has no permissions to access the converted data.\n }\n }\n }\n\n if (empty($crmData['ConvertedOpportunityId']) === false) {\n $convertedOpportunity = $this->config\n ->opportunities()\n ->where('crm_provider_id', $crmData['ConvertedOpportunityId'])\n ->first();\n\n if ($convertedOpportunity === null) {\n try {\n $convertedOpportunity = $this->syncOpportunity($crmData['ConvertedOpportunityId']);\n } catch (HttpNotFoundException $exception) {\n // Probably the user has no permissions to access the converted data.\n }\n }\n }\n\n if (empty($crmData['ConvertedContactId']) === false) {\n $convertedContact = $this->team\n ->crm\n ->contacts()\n ->where('crm_provider_id', $crmData['ConvertedContactId'])\n ->first();\n\n if ($convertedContact === null) {\n try {\n $convertedContact = $this->syncContact($crmData['ConvertedContactId']);\n } catch (HttpNotFoundException $exception) {\n // Probably the user has no permissions to access the converted data.\n }\n }\n }\n }\n\n if (empty($crmData['Company'])) {\n $company = 'Unknown';\n } else {\n $company = mb_strimwidth($crmData['Company'], 0, 191);\n }\n\n $domain = null;\n if (empty($crmData['Website']) === false) {\n $domain = mb_strimwidth($crmData['Website'], 0, 191);\n $domain = StringUtil::resolveDomain($domain);\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $data = [\n 'team_id' => $this->team->id,\n 'user_id' => $profile?->user_id,\n 'owner_id' => $crmData['OwnerId'] ?? '',\n 'company' => $company,\n 'domain' => $domain,\n 'name' => $crmData['Name'] ? mb_strimwidth($crmData['Name'], 0, 191) : '',\n 'title' => $crmData['Title'] ? mb_strimwidth($crmData['Title'], 0, 128) : null,\n 'email' => $crmData['Email'] ? mb_strimwidth($crmData['Email'], 0, 80) : null,\n 'phone' => $parsedNumber['phone'],\n 'ext' => $parsedNumber['ext'] ?? null,\n 'mobile_phone' => $mobilePhone,\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['Id'],\n modelType: Lead::class,\n fileName: $crmData['Id'],\n avatarText: $crmData['Name']\n ),\n 'stage_id' => $stage->id,\n 'record_type_id' => null,\n 'converted_at' => $convertedDate,\n 'converted_account_id' => $convertedAccount->id ?? null,\n 'converted_opportunity_id' => $convertedOpportunity->id ?? null,\n 'converted_contact_id' => $convertedContact->id ?? null,\n 'country_code' => $countryCode,\n 'remotely_created_at' => $createdDate,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->leads(), $crmData['Id']);\n\n /** @var Lead $lead */\n $lead = $this->config->leads()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n if ($lead->wasChanged('converted_at') && $lead->getConvertedAt() !== null) {\n $this->eventDispatcher->dispatch(new LeadConverted($lead));\n }\n\n $this->handleObjectDeletion($lead, $crmData);\n\n if ($lead->trashed()) {\n return null;\n }\n\n return $lead;\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccounts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n $fields = $this->getAllFieldsAsArray('account');\n\n if (\\in_array('Id', $fields, true) === false) {\n return $syncCount;\n }\n\n $query = '\n SELECT ' . rtrim(implode(',', $fields), ',') . '\n FROM Account\n WHERE LastModifiedDate > :since\n ORDER BY LastModifiedDate ASC';\n\n try {\n $sfAccounts = $this->queryHandler->query($query, [\n 'since' => $since->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($sfAccounts as $sfAccount) {\n // Only sync if previously imported.\n if ($this->hasAccount($sfAccount['Id'])) {\n $this->importAccount($sfAccount);\n $syncCount++;\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::ACCOUNT);\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccount(string $crmId): ?Account\n {\n $fields = $this->getAllFieldsAsArray('account');\n if (! in_array('Id', $fields, true)) {\n $this->logger->info('[Salesforce] Sync account cancelled. Fields are not available.', [\n 'crmId' => $crmId,\n 'userId' => $this->profile->getUserId(),\n ]);\n\n return null;\n }\n\n $sfAccount = $this->getRecord('Account', $crmId, $fields);\n\n return $this->importAccount($sfAccount);\n }\n\n private function importAccount($crmData): Account\n {\n $countryCode = $crmData['BillingCountryCode'] ?? $crmData['ShippingCountryCode'] ?? null;\n\n // Salesforce allows custom \"countries\" to be created. Disregard these.\n if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {\n $countryCode = null;\n }\n\n // If we have no country code, try to parse it from the country names.\n if ($countryCode === null && empty($crmData['BillingCountry']) === false) {\n $countryCode = $this->convertCountryNameToCode($crmData['BillingCountry']);\n }\n\n if ($countryCode === null && empty($crmData['ShippingCountry']) === false) {\n $countryCode = $this->convertCountryNameToCode($crmData['ShippingCountry']);\n }\n\n if (empty($crmData['Phone']) === false) {\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($crmData['Phone'], 0, 25);\n $parsedNumber = parsePhoneNumber($countryCode, $number);\n } else {\n $parsedNumber = [];\n }\n\n $industry = null;\n if (empty($crmData['Industry']) === false) {\n $industry = mb_strimwidth($crmData['Industry'], 0, 40);\n }\n\n $domain = null;\n if (empty($crmData['Website']) === false) {\n $domain = mb_strimwidth($crmData['Website'], 0, 191);\n $domain = StringUtil::resolveDomain($domain);\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $data = [\n 'team_id' => $this->team->id,\n 'user_id' => $profile?->user_id,\n 'owner_id' => $crmData['OwnerId'],\n 'name' => mb_strimwidth($crmData['Name'], 0, 191),\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['Id'],\n modelType: Account::class,\n fileName: $crmData['Id'],\n avatarText: $crmData['Name']\n ),\n 'industry' => $industry,\n 'domain' => $domain,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'ext' => $parsedNumber['ext'] ?? null,\n 'country_code' => $countryCode,\n 'remotely_created_at' => $createdDate,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->accounts(), $crmData['Id']);\n\n /** @var Account $account */\n $account = $this->config->accounts()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n $this->handleObjectDeletion($account, $crmData);\n\n return $account;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOpportunities(array $parameters, ?string $strategy = null): int\n {\n $strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);\n\n $syncCount = 0;\n $logParams = $parameters;\n $parameters['profile'] = $this->profile;\n $logParams['user'] = $this->profile->getUserId();\n\n if (count($strategies) > 1) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Multiple sync strategies used', [\n 'teamId' => $this->team->getUuid(),\n 'params' => $logParams,\n 'strategies_count' => count($strategies),\n ]);\n }\n\n foreach ($strategies as $syncStrategy) {\n $name = $syncStrategy->getStrategyName();\n\n try {\n $sfOpportunities = $syncStrategy->fetchOpportunities($parameters);\n $totalRecords = $sfOpportunities->count();\n\n foreach ($sfOpportunities as $sfOpportunity) {\n $this->importOpportunity($sfOpportunity);\n $syncCount++;\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n $this->logger->warning('[' . $this->getDisplayName() . '] No opportunities found', [\n 'teamId' => $this->team->getUuid(),\n 'name' => $name,\n 'params' => $logParams,\n 'reason' => $noResultsException->getMessage(),\n ]);\n } catch (CrmException $crmException) {\n // Nothing to sync.\n $this->logger->warning('[' . $this->getDisplayName() . '] Opportunity sync failed', [\n 'teamId' => $this->team->getUuid(),\n 'name' => $name,\n 'params' => $logParams,\n 'reason' => $crmException->getMessage(),\n ]);\n }\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::OPPORTUNITY, ['params' => $logParams]);\n\n // debug to see how if count of opportunities reaches 1000\n if ($syncCount >= 1000) {\n $this->logger->info(\n '[' . $this->getDisplayName() . '] Sync Opportunities - count warning',\n [\n 'team_id' => $this->team->getId(),\n 'params' => $logParams,\n 'count' => $syncCount,\n 'strategies_count' => count($strategies),\n 'total_records' => $totalRecords ?? null,\n ]\n );\n }\n\n return $syncCount;\n }\n\n\n /**\n * @inheritdoc\n */\n public function syncOpportunity(string $crmId): ?Opportunity\n {\n $strategy = $this->opportunitySyncStrategyResolver->resolve(\n $this->config,\n OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY\n );\n\n $parameters = [\n 'profile' => $this->profile,\n 'crm_id' => $crmId,\n ];\n\n try {\n $sfOpportunity = $strategy->fetchOpportunities($parameters);\n } catch (HttpNotFoundException $e) {\n $this->logger->info('[' . $this->getDisplayName() . '] Opportunity not found', [\n 'teamId' => $this->team->id_string,\n 'crmId' => $crmId,\n ]);\n\n return null;\n } catch (CrmException $crmException) {\n $this->logger->info('[' . $this->getDisplayName() . '] Opportunity sync failed', [\n 'teamId' => $this->team->id_string,\n 'crmId' => $crmId,\n 'exception' => $crmException->getMessage(),\n ]);\n\n return null;\n }\n\n if ($sfOpportunity instanceof ArrayIterator) {\n return $this->importOpportunity($sfOpportunity->getItems());\n }\n\n return $this->importOpportunity($sfOpportunity);\n }\n\n /**\n * @throws HttpNotFoundException\n */\n private function importOpportunity($crmData): ?Opportunity\n {\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $account = null;\n if (empty($crmData['AccountId']) === false) {\n /** @var ?Account $account */\n $account = $this->config->accounts()\n ->where('crm_provider_id', (string) $crmData['AccountId'])\n ->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmData['AccountId']);\n }\n }\n\n $userId = $profile?->getUserId() ?? $account?->getUserId();\n if ($userId === null) {\n $this->logger->error('[Salesforce] | Skip import, no user_id found', [\n 'id' => $crmData['Id'],\n ]);\n\n return null;\n }\n\n /** @var ?Stage $stage */\n $stage = null;\n if (isset($crmData['StageName'])) {\n $stage = $this->config\n ->stages()\n ->where('name', $crmData['StageName'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->orderBy('is_selectable', 'DESC')\n ->orderBy('id')\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $crmData['StageName']);\n }\n }\n\n $recordType = null;\n if (empty($crmData['RecordTypeId']) === false) {\n /** @var ?RecordType $recordType */\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $crmData['RecordTypeId'])\n ->first();\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $closeDate = null;\n if (empty($crmData['CloseDate']) === false) {\n $closeDate = Carbon::parse($crmData['CloseDate'])->format('Y-m-d');\n }\n\n $valueFieldName = 'Amount';\n if ($this->config->opportunity_value_field_id) {\n $valueFieldName = $this->config->opportunityValueField->crm_provider_id;\n }\n\n $data = [\n 'team_id' => $this->team->id,\n 'account_id' => $account->id ?? null,\n 'user_id' => $userId,\n 'owner_id' => $crmData['OwnerId'] ?? null,\n 'name' => mb_strimwidth($crmData['Name'] ?? '', 0, 128),\n 'value' => $crmData[$valueFieldName],\n 'currency_code' => CurrencyFormatter::formatCode($crmData['CurrencyIsoCode'] ?? null),\n 'close_date' => $closeDate,\n 'is_closed' => $crmData['IsClosed'],\n 'is_won' => $crmData['IsWon'],\n 'stage_id' => $stage?->id ?? null,\n 'record_type_id' => $recordType->id ?? null,\n 'remotely_created_at' => $createdDate,\n 'probability' => $crmData['Probability'] ?? null,\n 'forecast_category' => $crmData['ForecastCategoryName'] ?? null,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->opportunities(), $crmData['Id']);\n\n // Do not allow locked DB tables & other errors\n // to interrupt the process of reverting the trashed opportunities\n try {\n /** @var Opportunity $opportunity */\n $opportunity = $this->config->opportunities()\n ->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n // import external fields into crm_field_data if present\n $crmFields = $this->getOpportunitySyncableFields();\n\n $this->importOpportunityCrmFieldData($crmData, $crmFields, $opportunity->id);\n\n $this->handleObjectDeletion($opportunity, $crmData);\n\n if ($opportunity->trashed()) {\n return null;\n }\n\n if ($opportunity->wasRecentlyCreated) {\n MatchActivitiesToNewOpportunity::dispatch($opportunity->getId());\n }\n\n return $opportunity;\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n $this->logger->error('[Salesforce] importOpportunity failure.', [\n 'crm_provider_id' => $crmData['Id'],\n 'team_id' => $this->team->id,\n 'exception' => $exception->getMessage(),\n ]);\n\n $this->handleEntityDeletionByProviderId($this->config->opportunities(), $crmData);\n }\n\n return null;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContacts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n $fields = $this->getAllFieldsAsArray('contact');\n if (\\in_array('Id', $fields, true) === false) {\n return $syncCount;\n }\n\n $query = '\n SELECT ' . rtrim(implode(',', $fields), ',') . '\n FROM Contact\n WHERE LastModifiedDate > :since\n ORDER BY LastModifiedDate ASC';\n\n try {\n $sfContacts = $this->queryHandler->query($query, [\n 'since' => $since->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($sfContacts as $sfContact) {\n // Only sync if previously imported.\n if ($this->hasContact($sfContact['Id'])) {\n $this->importContact($sfContact);\n $syncCount++;\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::CONTACT);\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContact(string $crmId): ?Contact\n {\n $fields = $this->getAllFieldsAsArray('contact');\n if (! in_array('Id', $fields, true)) {\n $this->logger->info('[Salesforce] Sync contact cancelled. Fields are not available.', [\n 'crmId' => $crmId,\n 'userId' => $this->profile->getUserId(),\n ]);\n\n return null;\n }\n\n $sfContact = $this->getRecord('Contact', $crmId, $fields);\n\n return $this->importContact($sfContact);\n }\n\n private function importContact($crmData): ?Contact\n {\n $account = null;\n // Contacts may not have accounts...\n if (isset($crmData['AccountId'])) {\n $account = $this->config->accounts()\n ->where('crm_provider_id', (string) $crmData['AccountId'])\n ->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmData['AccountId']);\n }\n }\n\n $countryCode = $crmData['MailingCountryCode'] ?? null;\n\n // Salesforce allows custom \"countries\" to be created. Disregard these.\n if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {\n $countryCode = null;\n }\n\n // If we have no country code, try to parse it from the country name.\n if ($countryCode === null && empty($crmData['MailingCountry']) === false) {\n $countryCode = $this->convertCountryNameToCode($crmData['MailingCountry']);\n\n if ($countryCode === null && $account) {\n $countryCode = $account->country_code;\n }\n }\n\n $ext = null;\n $parsedNumber = [];\n if (empty($crmData['Phone']) === false) {\n $number = Str::limit($crmData['Phone'], 25, '');\n $parsedNumber = parsePhoneNumber($countryCode, $number);\n\n if (empty($parsedNumber['ext']) === false) {\n $ext = Str::limit($parsedNumber['ext'], 10, '');\n }\n }\n\n $mobileNumber = null;\n if (empty($crmData['MobilePhone']) === false) {\n $mobileNumber = Str::limit(phone_e164($countryCode, $crmData['MobilePhone']), 25, '');\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $data = [\n 'team_id' => $this->team->id,\n 'account_id' => $account->id ?? null,\n 'user_id' => $profile?->user_id,\n 'owner_id' => $crmData['OwnerId'] ?? null,\n 'name' => ($crmData['Name'] ?? null) !== null ? mb_strimwidth($crmData['Name'], 0, 100) : '',\n 'title' => ($crmData['Title'] ?? null) !== null ? mb_strimwidth($crmData['Title'], 0, 128) : null,\n 'email' => ($crmData['Email'] ?? null) !== null ? mb_strimwidth($crmData['Email'], 0, 191) : null,\n 'country_code' => $countryCode,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'ext' => $ext,\n 'mobile_phone' => $mobileNumber,\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['Id'],\n modelType: Contact::class,\n fileName: $crmData['Id'],\n avatarText: $crmData['Name']\n ),\n 'remotely_created_at' => $createdDate,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->contacts(), $crmData['Id']);\n\n /** @var Contact $contact */\n $contact = $this->config->contacts()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n $this->handleObjectDeletion($contact, $crmData);\n\n if ($contact->trashed()) {\n return null;\n }\n\n return $contact;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n $fields = [\n 'InstanceName',\n 'OrganizationType',\n 'IsSandbox',\n ];\n\n $orgValues = $this->getRecord('Organization', $this->config->crm_provider_id, $fields);\n\n $edition = null;\n switch ($orgValues['OrganizationType']) {\n case 'Developer Edition':\n $edition = Configuration::EDITION_DEVELOPER;\n\n break;\n\n case 'Professional Edition':\n $edition = Configuration::EDITION_PROFESSIONAL;\n\n break;\n\n case 'Enterprise Edition':\n $edition = Configuration::EDITION_ENTERPRISE;\n\n break;\n }\n\n $this->config->edition = $edition;\n $this->config->instance = $orgValues['InstanceName'];\n\n // XXX: How can this state be possible?\n if ($this->config->version === null) {\n $this->config->version = Client::MIN_API_VERSION;\n }\n\n $installedVersion = $this->getInstalledAppVersion();\n if ($installedVersion !== null) {\n $installedVersion = (string) $this->getInstalledAppVersion();\n }\n\n $this->config->installed_app_version = $installedVersion;\n\n $this->config->save();\n }\n\n public function getInstalledAppVersion(): ?string\n {\n try {\n $query = '\n SELECT\n SubscriberPackageVersion.MajorVersion,\n SubscriberPackageVersion.MinorVersion,\n SubscriberPackageVersion.PatchVersion,\n SubscriberPackageVersion.BuildNumber\n FROM\n InstalledSubscriberPackage\n WHERE\n SubscriberPackageId = :packageId\n ';\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'packageId' => self::INSTALLED_PACKAGE_ID,\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n // Grab version number.\n $version = $sfField['SubscriberPackageVersion']['MajorVersion'] .\n $sfField['SubscriberPackageVersion']['MinorVersion'] .\n $sfField['SubscriberPackageVersion']['PatchVersion'] .\n $sfField['SubscriberPackageVersion']['BuildNumber'];\n } catch (\\Exception) {\n $version = null;\n }\n\n return $version;\n }\n\n /**\n * Store transcripts as note.\n *\n * @throws \\Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // For SF we also check if Log Notes is enabled.\n if ($this->profile->log_notes === Profile::LOG_NOTE_NONE) {\n return;\n }\n\n if ($activity->opportunity_id && $activity->prospect === null) {\n return;\n }\n\n try {\n $transcriptionData = $this->generateTranscription($activity);\n\n $noteMaxLength = $this->profile->log_notes === Profile::LOG_NOTE_ENHANCED\n ? self::ENHANCED_NOTE_MAX_LENGTH\n : self::CLASSIC_NOTE_MAX_LENGTH;\n\n $title = 'Transcript for ';\n $title .= $activity->title ?? $activity->activity_title;\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $body = mb_strimwidth($transcriptionData, 0, $noteMaxLength);\n\n if ($activity->opportunity_id) {\n $objectId = $activity->opportunity->crm_provider_id;\n } else {\n $objectId = $activity->prospect->crm_provider_id;\n }\n\n $noteId = $this->saveNote($title, $body, $objectId);\n\n // Store crm logged id in transcription.\n $transcription = $activity->getTranscription();\n $transcription->crm_activity_id = $noteId;\n $transcription->save();\n } catch (\\Exception $e) {\n \\Sentry::captureException($e);\n }\n }\n\n public function saveNote(string $title, string $body, string $objectId, ?NoteObject $noteObject = null): ?string\n {\n $noteId = null;\n\n try {\n if ($this->profile->log_notes === Profile::LOG_NOTE_ENHANCED) {\n $noteId = $this->buildEnhancedNote($title, $body, $objectId);\n } else {\n $noteId = $this->buildClassicNote($title, $body, $objectId);\n }\n } catch (HttpNotFoundException $exception) {\n // The profile not having access to create Enhanced Notes. Set their preference to Classic.\n if ($this->profile->log_notes === Profile::LOG_NOTE_ENHANCED) {\n $this->profile->update([\n 'log_notes' => Profile::LOG_NOTE_CLASSIC,\n ]);\n }\n }\n\n return $noteId;\n }\n\n /**\n * This is using the \"Enhanced\" Notes feature, NOT the \"Notes & Attachments\" feature being deprecated.\n *\n * @url https://salesforce.stackexchange.com/questions/104408/how-can-i-create-an-account-note-or-contact-note-via-api-that-is-visible-in-sale\n */\n private function buildEnhancedNote(string $title, string $body, string $objectId): string\n {\n // Decode stored entities, escape HTML (without quoting), then convert line breaks for Salesforce formatting\n $decodedBody = html_entity_decode($body, ENT_QUOTES | ENT_HTML5);\n $sanitizedBody = htmlspecialchars($decodedBody, ENT_NOQUOTES, 'UTF-8', false);\n $content = nl2br($sanitizedBody, false);\n $note = [\n 'OwnerId' => $this->profile->crm_provider_id,\n 'Title' => $title,\n 'Content' => base64_encode($content),\n ];\n\n $noteId = $this->createRecord('ContentNote', $note);\n\n $link = [\n 'ContentDocumentId' => $noteId,\n 'LinkedEntityId' => $objectId,\n 'ShareType' => 'I',\n ];\n\n $this->createRecord('ContentDocumentLink', $link);\n\n return $noteId;\n }\n\n private function buildClassicNote(string $title, string $body, string $objectId): string\n {\n if (in_array($this->parseObjectType($objectId), [Field::OBJECT_TASK, Field::OBJECT_EVENT])) {\n $this->logger->info('[Salesforce] Summary not sent', [\n 'profile_id' => $this->profile->id,\n 'objectId' => $objectId,\n 'reason' => 'Classical Note does not support Task/Event relation',\n ]);\n\n return '';\n }\n\n $titleTrimmed = null;\n\n if (mb_strlen($title) > 80) {\n $titleTrimmed = substr($title, 0, 77) . '...';\n }\n $payload = [\n 'OwnerId' => $this->profile->crm_provider_id,\n 'IsPrivate' => false,\n 'Title' => $titleTrimmed ?? $title,\n 'Body' => $titleTrimmed ? $title . PHP_EOL . $body : $body,\n 'ParentId' => $objectId,\n ];\n\n return $this->createRecord('Note', $payload);\n }\n\n /**\n * @inheritdoc\n */\n public function find(string $name, array $scopes): array\n {\n if ($this->profile === null) {\n return [];\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $limitValues = ['limit' => $this->limit, 'offset' => $this->offset];\n $sosl = $queryBuilder->buildFindQuery($name, $scopes, $limitValues);\n\n $this->logger->info('[Salesforce] Find prospects', [\n 'profile_id' => $this->profile->id,\n 'sosl_query' => $sosl,\n 'search_string' => $name,\n 'scopes' => $scopes,\n ]);\n\n $data = Cache::remember($this->profile->id . $sosl, self::CACHE_TTL, function () use ($sosl) {\n $data = [];\n\n try {\n // Hit remote API.\n $objects = $this->queryHandler->search($sosl);\n\n // Build mapped list.\n foreach ($objects as $object) {\n $type = strtolower($object['attributes']['type']);\n\n $record = [\n 'crmId' => $object['Id'],\n 'name' => $object['Name'],\n 'prospectType' => $type,\n 'phoneNumbers' => [],\n 'crmUrl' => $this->generateProviderUrl($object['Id'], $type),\n ];\n\n switch ($type) {\n case 'lead':\n if (empty($object['Company']) === false) {\n $record['organization'] = $object['Company'];\n }\n\n if (empty($object['Title']) === false) {\n $record['title'] = $object['Title'];\n }\n\n $stage = $this->config->stages()\n ->where('type', Stage::TYPE_LEAD)\n ->where('name', $object['Status'])\n ->first();\n\n // Lazy create the stage.\n if ($stage === null) {\n $stage = $this->importStages([Stage::TYPE_LEAD], $object['Status']);\n }\n\n if ($stage) {\n $record += [\n 'stage' => [\n 'id' => $stage->id_string,\n 'name' => $stage->name,\n ],\n ];\n }\n\n if (empty($object['RecordTypeId']) === false) {\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $object['RecordTypeId'])\n ->first();\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n }\n\n break;\n\n case 'account':\n if (empty($object['Industry']) === false) {\n $record['industry'] = $object['Industry'];\n $record['detailsLine'] = $object['Industry'];\n }\n if (! empty($object['PersonEmail'])) {\n $record['detailsLine'] = $object['PersonEmail'];\n }\n\n break;\n\n case 'contact':\n // For contacts, we should try and fetch their account name too.\n if ($object['AccountId']) {\n // Cheaper to get this locally.\n $account = $this->config->accounts()\n ->where('crm_provider_id', $object['AccountId'])\n ->first(['name']);\n\n if ($account) {\n $record['organization'] = $account->name;\n }\n }\n\n if (! empty($object['IsPersonAccount']) && $object['Email']) {\n $record['detailsLine'] = $object['Email'];\n } else {\n if (empty($object['Title']) === false) {\n $record['title'] = $object['Title'];\n }\n }\n\n break;\n }\n\n // Add phone numbers to record.\n if (empty($object['Phone']) === false && $object['Phone']) {\n $record['phoneNumbers'][] = [\n 'number' => $object['Phone'],\n 'nationalFormat' => phone_national($this->profile->user->country_code, $object['Phone']),\n 'type' => 'phone',\n ];\n }\n\n if (empty($object['MobilePhone']) === false && $object['MobilePhone']) {\n $record['phoneNumbers'][] = [\n 'number' => $object['MobilePhone'],\n 'nationalFormat' => phone_national(\n $this->profile->user->country_code,\n $object['MobilePhone']\n ),\n 'type' => 'mobile',\n ];\n }\n\n $data[] = $record;\n }\n } catch (NoResultsException $e) {\n $data = [];\n }\n\n return $data;\n });\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n\n if ($crmAccountId === null) {\n return $data;\n }\n\n if ($userId) {\n $profileRepository = app(ProfileRepository::class);\n $profile = $profileRepository->findProfileByUserId($this->config, $userId);\n\n $ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;\n }\n\n try {\n // Perhaps their profile has no opportunity permissions.\n if ($this->profile === null || $this->profile->opportunity_fields === null) {\n return $data;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $query = $queryBuilder->buildFindOpportunitiesQuery();\n\n $objects = $this->queryHandler->query($query, ['accountId' => $crmAccountId]);\n\n foreach ($objects as $object) {\n $record = [\n 'crmId' => $object['Id'],\n 'name' => $object['Name'],\n 'won' => $object['IsWon'],\n 'closed' => $object['IsClosed'],\n ];\n\n $valueFieldName = 'Amount';\n if ($this->config->opportunity_value_field_id) {\n $valueFieldName = $this->config->opportunityValueField->crm_provider_id;\n }\n\n if (empty($object[$valueFieldName]) === false) {\n $currency = $object['CurrencyIsoCode'] ?? $this->config->default_currency;\n $value = formatCurrency($object[$valueFieldName], $currency);\n\n $record += [\n 'value' => $value,\n ];\n }\n\n $stage = $this->config->stages()\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->where('name', $object['StageName'])\n ->first();\n\n // Lazy create the stage.\n if ($stage === null) {\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $object['StageName']);\n }\n\n $record += [\n 'stage' => [\n 'id' => $stage->id_string,\n 'name' => $stage->name,\n ],\n ];\n\n if (empty($object['RecordTypeId']) === false) {\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $object['RecordTypeId'])\n ->first();\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n }\n\n if ($ownerId && isset($object['OwnerId']) && $object['OwnerId'] === $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n } catch (NoResultsException $e) {\n return $data;\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n public function getContactRolesFromCrm(?Carbon $since = null): array\n {\n $roles = [];\n\n if ($this->profile === null) {\n return $roles;\n }\n\n $queryBuilder = app(QueryBuilder::class, ['profile' => $this->profile]);\n\n $query = $queryBuilder->buildGetContactRolesQuery($since);\n\n try {\n $objects = $this->queryHandler->query($query);\n\n foreach ($objects as $object) {\n $roles[] = [\n 'id' => $object['Id'],\n 'contactId' => $object['ContactId'],\n 'opportunityId' => $object['OpportunityId'],\n 'ownerId' => $object['Opportunity']['OwnerId'] ?? null,\n 'isPrimary' => $object['IsPrimary'],\n 'role' => $object['Role'],\n ];\n }\n } catch (NoResultsException) {\n // Just return an empty array.\n $this->logger->info('[Salesforce] No contact roles found', [\n 'since' => $since?->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n }\n\n return $roles;\n }\n\n public function syncContactRoles(Carbon $since): int\n {\n $contactRoleRepository = app(ContactRoleRepository::class);\n\n $crmContactRoles = $this->getContactRolesFromCrm(since: $since);\n $syncCount = 0;\n $contactRoles = [];\n\n foreach ($crmContactRoles as $crmContactRole) {\n $contactRoles[] = $this->importContactRole($crmContactRole);\n $syncCount++;\n }\n\n $contactRoleRepository->saveContactRoles($contactRoles);\n\n $this->syncRemotelyDeletedContactRoles();\n\n return $syncCount;\n }\n\n private function importContactRole(array $contactRole): array\n {\n $contact = $this->config->contacts()\n ->where('crm_provider_id', $contactRole['contactId'])\n ->first();\n\n if ($contact === null) {\n $contact = $this->syncContact($contactRole['contactId']);\n }\n\n $opportunity = $this->config->opportunities()\n ->where('crm_provider_id', $contactRole['opportunityId'])\n ->first();\n\n if ($opportunity === null) {\n $opportunity = $this->syncOpportunity($contactRole['opportunityId']);\n }\n\n $role = null;\n if (! empty($contactRole['role'])) {\n $role = mb_strimwidth($contactRole['role'], 0, 191);\n }\n\n return [\n 'crm_configuration_id' => $this->config->getId(),\n 'contact_id' => $contact->getId(),\n 'crm_provider_id' => $contactRole['id'],\n 'subject_type' => ContactRole::SUBJECT_TYPE_OPPORTUNITY,\n 'subject_id' => $opportunity->getId(),\n 'is_primary' => $contactRole['isPrimary'],\n 'role' => $role,\n ];\n }\n\n protected function syncRemotelyDeletedContactRoles(): bool\n {\n try {\n $deletedRemotely = $this->queryHandler->queryDeleted('OpportunityContactRole');\n } catch (NoResultsException $e) {\n return false;\n }\n\n $deletedOpportunities = $deletedRemotely->getResults();\n $deletedIds = array_column($deletedOpportunities, 'id');\n\n $contactRoleRepository = app(ContactRoleRepository::class);\n\n foreach (array_chunk($deletedIds, self::HARD_DELETE_CHUNK) as $chunk) {\n $contactRoleRepository->deleteContactRoles($chunk);\n\n $this->logger->info('[' . $this->getDisplayName() . '] Remotely deleted opportunities synced', [\n 'teamId' => $this->team->id_string,\n 'remotelyDeletedOpportunities' => $chunk,\n 'count' => count($chunk),\n ]);\n }\n\n return true;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = $objects = [];\n\n $hasWho = \\in_array($objectType, ['lead', 'contact']);\n $playbook = $this->getPlaybook($this->profile->user);\n $fields = array_merge(\n $this->profile->getFieldsAsArray(parent::OBJECT_TASK),\n $playbook && $playbook->activityField ? [$playbook->activityField->crm_provider_id] : []\n );\n\n // Query should default to any open call for that user.\n $query = '\n SELECT ' . implode(',', array_unique($fields)) . '\n FROM Task\n WHERE OwnerId = :ownerId\n AND IsArchived = false\n AND IsDeleted = false\n AND IsClosed = false\n AND (';\n\n if ($objectType === 'account') {\n // This covers tasks tied to a related contact or opportunity too.\n $query .= '\n AccountId = :accountId';\n }\n\n if ($hasWho) {\n $query .= '\n WhoId = :whoId';\n\n // If we are also going to check on a specific opportunity, set that up.\n if ($opportunityId) {\n $query .= ' OR WhatId = :whatId';\n }\n }\n\n $query .= ' ) ORDER BY LastModifiedDate DESC';\n\n try {\n $objects = $this->queryHandler->query($query, [\n 'ownerId' => $this->profile->crm_provider_id,\n 'whoId' => $objectId,\n 'whatId' => $opportunityId,\n 'accountId' => $objectId,\n ]);\n } catch (NoResultsException $e) {\n return $data;\n } finally {\n $this->logger->debug(sprintf('[Salesforce] Found %s tasks for query \"%s\"', count($objects), $query));\n }\n\n foreach ($objects as $object) {\n $dueDate = $object['ActivityDate'] ? Carbon::parse($object['ActivityDate'])->toIso8601String() : null;\n $data[] = [\n 'crmId' => $object['Id'],\n 'subject' => $object['Subject'],\n 'due' => $dueDate,\n 'type' => $object[$playbook->activityField->crm_provider_id],\n ];\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getEvents(string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = $objects = [];\n $user = $this->profile?->user;\n if ($this->profile === null || $user === null) {\n return $data;\n }\n\n $hasWho = \\in_array($objectType, ['lead', 'contact']);\n $playbook = $this->getPlaybook($user);\n $fields = array_merge(\n $this->profile->getFieldsAsArray(parent::OBJECT_EVENT),\n $playbook && $playbook->activityField ? [$playbook->activityField->crm_provider_id] : []\n );\n\n // Query should default to any event starting in the last week and ending up until today owned by the user.\n $query = '\n SELECT ' . implode(',', array_unique($fields)) . '\n FROM Event\n WHERE OwnerId = :ownerId\n AND IsArchived = false\n AND IsAllDayEvent = false\n AND StartDateTime >= LAST_N_DAYS:7\n AND EndDateTime <= TODAY\n AND (';\n\n if ($objectType === 'account') {\n // This covers events tied to a related contact or opportunity too.\n $query .= '\n AccountId = :accountId';\n }\n\n if ($hasWho) {\n $query .= '\n WhoId = :whoId';\n\n // If we are also going to check on a specific opportunity, set that up.\n if ($opportunityId) {\n $query .= ' OR WhatId = :whatId';\n }\n }\n\n $query .= ' ) ORDER BY LastModifiedDate DESC';\n\n try {\n $objects = $this->queryHandler->query($query, [\n 'ownerId' => $this->profile->crm_provider_id,\n 'whoId' => $objectId,\n 'whatId' => $opportunityId,\n 'accountId' => $objectId,\n ]);\n } catch (NoResultsException $e) {\n return $data;\n } finally {\n $this->logger->debug(sprintf('[Salesforce] Found %s tasks for query \"%s\"', count($objects), $query));\n }\n\n foreach ($objects as $object) {\n $dueDate = $object['StartDateTime'] ? Carbon::parse($object['StartDateTime'])->toIso8601String() : null;\n\n $data[] = [\n 'crmId' => $object['Id'],\n 'subject' => $object['Subject'],\n 'due' => $dueDate,\n 'type' => $object[$playbook->activityField->crm_provider_id],\n ];\n }\n\n return $data;\n }\n\n /**\n * Try to find CRM Objects using email address\n *\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchExactlyByEmail(string $email, ?int $userId = null): ?array\n {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $sosl = $queryBuilder->buildMatchByQuery($email, Field::TYPE_EMAIL);\n if ($sosl === null) {\n return null;\n }\n\n try {\n $objects = $this->queryHandler->search($sosl);\n $objects = $this->queryHandler->prioritiseResults(\n $objects,\n $email,\n QueryHandler::PRIORITISE_EMAIL\n );\n\n $data = $this->convertCrmData($objects, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (NoResultsException $e) {\n // Try the account next.\n if ($this->profile->account_fields === null) {\n return null;\n }\n }\n\n return null;\n }\n\n public function getDomain(string $email): ?string\n {\n // SF improved search - strip the domain extension, min domain name length 4\n return $this->getCompanyNameFromEmail(email: $email, minNameLength: 4);\n }\n\n /**\n * Try to find CRM objects using domain name of the email address\n *\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByDomain(string $domain, ?int $userId = null): ?array\n {\n $companyName = $domain;\n\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $sosl = $queryBuilder->buildMatchByDomainQuery($companyName);\n\n try {\n $objects = $this->queryHandler->search($sosl);\n\n $data = $this->convertCrmData($objects, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (NoResultsException) {\n return null;\n }\n }\n\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n // Don't bother looking up numbers that are masked.\n if (str_contains($phone, '**')) {\n return null;\n }\n\n if ($this->isPhoneNumberOfTeamMember($phone)) {\n return null;\n }\n\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $phoneNational = phone_national(null, $phone) ?? '';\n $possiblePhoneFormats = collect([\n preg_replace('/\\D/', '', ltrim($phone, '0+')),\n preg_replace('/\\D/', '', $phoneNational),\n formatDashPhoneNumber($phone),\n $phoneNational,\n ])\n ->filter() // Removes null and empty strings\n ->unique()\n ->values();\n\n foreach ($possiblePhoneFormats as $phone) {\n $sosl = $queryBuilder->buildMatchByQuery($phone, Field::TYPE_PHONE);\n if ($sosl === null) {\n continue;\n }\n\n try {\n $objects = $this->queryHandler->search($sosl);\n $objects = $this->queryHandler->prioritiseResults(\n $objects,\n $phone,\n QueryHandler::PRIORITISE_PHONE\n );\n\n return $this->convertCrmData($objects, $userId);\n } catch (NoResultsException) {\n continue;\n }\n }\n\n return null;\n }\n\n private function isPhoneNumberOfTeamMember(string $phone): bool\n {\n $teamRepository = app(TeamRepository::class);\n $user = $teamRepository->findTeamMemberByPhone($this->team, $phone);\n\n if ($user instanceof User) {\n return true;\n }\n\n return false;\n }\n\n protected function getCacheKey(string $object, ?int $userId = null): ?string\n {\n $key = $this->profile->id . $object;\n $keySuffix = $this->getOwnerKeySuffix($userId);\n\n return $key . $keySuffix;\n }\n\n private function getOwnerKeySuffix(?int $userId = null): string\n {\n return $userId === null ? '' : (string) $userId;\n }\n\n /** Determine the CRM Objects which represent the call activity. */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n if ($this->profile === null) {\n return null;\n }\n\n $cacheKey = $this->getCacheKey($name, $userId);\n\n $result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $sosl = $queryBuilder->buildMatchByQuery($name, 'name');\n if ($sosl === null) {\n return false;\n }\n\n try {\n $objects = $this->queryHandler->search($sosl);\n } catch (NoResultsException $e) {\n return false;\n }\n\n $objects = $this->queryHandler->prioritiseResults(\n $objects,\n $name,\n QueryHandler::PRIORITISE_NAME\n );\n\n $data = $this->convertCrmData($objects, $userId);\n\n return (! empty(array_filter($data))) ? $data : false;\n });\n\n return is_array($result) ? $result : null;\n }\n\n /**\n * @return array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n protected function convertCrmData(QueryIterator $objects, ?int $userId = null): array\n {\n $lead = null;\n $contact = null;\n $opportunity = null;\n $account = null;\n $stage = null;\n $countryCode = null;\n\n if ($objects->count() > 0) {\n $object = $objects->current();\n\n if ($object['attributes']['type'] === 'Lead') {\n $lead = $this->importLead($object);\n\n // Lead might not be imported if the Stage is null for example.\n if ($lead) {\n $countryCode = $lead->country_code;\n $stage = $lead->stage;\n }\n } else {\n if ($object['attributes']['type'] === 'Contact') {\n $contact = $this->importContact($object);\n $account = $contact->account;\n } else {\n $account = $this->importAccount($object);\n }\n\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account) {\n $countryCode = $account->country_code;\n }\n\n try {\n $sfOpportunities = $this->findOpportunities(\n $account?->getCrmProviderId(),\n $contact?->getCrmProviderId(),\n $userId\n );\n\n // Take the first opportunity, which will be ordered as priority based on their settings.\n if (! empty($sfOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($sfOpportunities[0]['crmId']);\n $stage = $opportunity?->stage;\n }\n } catch (Exception) {\n // Nothing to see here.\n }\n }\n }\n\n return [\n $lead,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n /**\n * @inheritdoc\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n if ($stage->type === Stage::TYPE_LEAD) {\n $objectType = 'Lead';\n $objectId = $crmObject->crm_provider_id;\n $objectStageType = 'Status';\n } else {\n $objectType = 'Opportunity';\n $objectId = $crmObject->crm_provider_id;\n $objectStageType = 'StageName';\n }\n\n $headers = [];\n if ($this->config->trigger_assignment_rules === false) {\n // @see: https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/headers_autoassign.htm\n $headers = [\n 'Sforce-Auto-Assign' => 'false',\n ];\n }\n\n $this->updateRecord($objectType, $objectId, [$objectStageType => $stage->name], $headers);\n }\n\n public function parseObjectType(string $objectId): string\n {\n if (Str::startsWith($objectId, '001')) {\n return 'account';\n }\n\n if (Str::startsWith($objectId, '003')) {\n return 'contact';\n }\n\n if (Str::startsWith($objectId, '00Q')) {\n return 'lead';\n }\n\n if (Str::startsWith($objectId, '006')) {\n return 'opportunity';\n }\n\n if (Str::startsWith($objectId, '00U')) {\n return 'event';\n }\n\n if (Str::startsWith($objectId, '00T')) {\n return 'task';\n }\n\n throw new \\InvalidArgumentException('Unsupported Object Type');\n }\n\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, ['profile' => $this->profile]);\n $query = $queryBuilder->buildGetUsersQuery($userToSearch);\n\n try {\n $salesforceUsers = $this->queryHandler->query($query, [\n 'active' => true,\n ]);\n } catch (NoResultsException $e) {\n $this->logger->info('[Salesforce] Sync Profiles. No users found', [\n 'query' => $query,\n 'error' => $e->getMessage(),\n ]);\n\n return null;\n }\n\n $teamRepository = app(TeamRepository::class);\n $customRules = $this->getCustomProfileRules($teamRepository);\n\n foreach ($salesforceUsers as $crmUser) {\n if ($crmUser['Email'] === null) {\n continue;\n }\n\n if (! $this->customProfileValidation($crmUser, $customRules)) {\n continue;\n }\n\n $user = $teamRepository->findActiveTeamMemberByEmail($this->team, $crmUser['Email']);\n\n if (! $user instanceof User) {\n continue;\n }\n\n $edition = $crmUser['UserPreferencesLightningExperiencePreferred']\n ? Profile::EDITION_LIGHTNING\n : Profile::EDITION_CLASSIC;\n\n $profileRepository = app(ProfileRepository::class);\n $profile = $profileRepository->updateOrCreateProfile(\n $user,\n [\n 'crm_configuration_id' => $this->config->getId(),\n 'crm_provider_id' => $crmUser['Id'],\n ],\n [\n 'user_id' => $user->getId(),\n 'edition' => $edition,\n 'has_external_cti' => ! empty($crmUser['CallCenterId']),\n 'crm_profile_id' => $crmUser['ProfileId'],\n ]\n );\n\n if ($userToSearch instanceof User && $userToSearch->getId() === $user->getId()) {\n return $profile;\n }\n }\n\n // Clean up inactive profiles\n try {\n $this->archiveInactiveProfiles();\n } catch (\\Exception $e) {\n $this->logger->warning('[Salesforce] Profile archiving failed', [\n 'teamId' => $this->team->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $url = null;\n\n // For Salesforce it's easy, we just point every object to the apex domain and they handle it.\n switch ($objectType) {\n case 'lead':\n case 'account':\n case 'contact':\n case 'opportunity':\n case 'task':\n case 'event':\n case 'activity':\n\n $url = $this->config->crm_base_url . '/' . $providerId;\n\n break;\n }\n\n return $url;\n }\n\n public function buildTaskSearchFields(): array\n {\n return ['Id', 'WhoId', 'WhatId', 'AccountId'];\n }\n\n public function getTaskByFilterConditions(\n array $fields,\n array $filters,\n bool $bulkSearch = false,\n bool $strictFilters = true\n ): ?array {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $query = $queryBuilder->buildSearchTaskQuery($fields, $filters, $bulkSearch, $strictFilters);\n\n try {\n if (! $bulkSearch) {\n $objects = $this->queryHandler->query($query, $filters);\n if ($objects->count() === 1) {\n return $objects->current();\n }\n }\n\n if ($bulkSearch) {\n $objects = $this->queryHandler->query($query);\n $records = [];\n foreach ($objects as $record) {\n $key = $record[end($fields)];\n $records[$key] = $record;\n }\n\n return $records;\n }\n } catch (\\Exception $e) {\n $this->logger->info('[Salesforce] Failed to execute query', [\n 'query' => $query,\n 'error' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function mapCrmObjects(array $task): array\n {\n $activityData = [];\n\n if (! empty($task['WhoId'])) {\n $type = $this->parseObjectType($task['WhoId']);\n $activityData[$type] = $task['WhoId'];\n }\n if (! empty($task['AccountId'])) {\n $activityData['account'] = $task['AccountId'];\n }\n if (! empty($task['WhatId'])) {\n $activityData['opportunity'] = $task['WhatId'];\n }\n\n return $activityData;\n }\n\n /**\n * Get SF task by Outreach call id.\n */\n public function getTaskByFilter(\n string $activityFieldType,\n array $filters,\n string $operator = '=',\n array $additionalFields = []\n ): ?array {\n $data = [];\n\n try {\n // Default (base) fields.\n $fields = ['Id', 'Subject', 'Description', 'ActivityDate', 'WhoId', 'WhatId', $activityFieldType];\n\n foreach ($additionalFields as $additionalField) {\n $fields[] = $additionalField->crm_provider_id;\n }\n\n $fields = array_unique($fields);\n\n // Find task with the same Outreach id as the call id.\n $query = 'SELECT ' . implode(',', $fields) . '\n FROM Task\n WHERE IsArchived = false AND IsDeleted = false';\n\n foreach ($filters as $key => $value) {\n $key = preg_quote($key, '/');\n $key = str_replace(['\\'', '\"'], '', $key);\n // Prepare the substitution.\n $strKey = \":$key\";\n\n $query .= \" AND $key $operator $strKey\";\n }\n\n $query .= ' ORDER BY LastModifiedDate DESC LIMIT 1';\n\n $objects = $this->queryHandler->query($query, $filters);\n\n // There should be only one task related to this call if any.\n if ($objects->count() === 1) {\n $object = $objects->current();\n\n $dueDate = $object['ActivityDate'] ? Carbon::parse($object['ActivityDate'])->toIso8601String() : null;\n\n $data = array_merge($object, [\n 'crmId' => $object['Id'],\n 'subject' => $object['Subject'],\n 'summary' => $object['Description'],\n 'due' => $dueDate,\n 'Type' => $object[$activityFieldType],\n ]);\n }\n } catch (NoResultsException $e) {\n // Filters don't match any records.\n } catch (ServiceUnavailableException $serviceUnavailableException) {\n // Service cannot be queried. We should probably log this.\n }\n\n return $data;\n }\n\n /**\n * Get Salesforce fields including datetime fields\n *\n * @param $objectType\n */\n private function getAllFieldsAsArray($objectType): array\n {\n $basicFields = [];\n // Not all users have access to all object fields.\n if ($this->profile->{$objectType . '_fields'}) {\n $basicFields = explode(',', $this->profile->{$objectType . '_fields'});\n }\n\n $extraFields = [\n 'CreatedDate',\n 'LastModifiedDate',\n 'IsDeleted',\n ];\n\n if ($objectType === self::OBJECT_OPPORTUNITY\n && $this->config->opportunity_value_field_id\n && ! in_array($this->config->opportunityValueField->crm_provider_id, $basicFields)\n ) {\n $extraFields[] = $this->config->opportunityValueField->crm_provider_id;\n }\n\n return array_unique(array_merge($basicFields, $extraFields));\n }\n\n /**\n * Generate transcription for activity description.\n */\n private function generateTranscription(Activity $activity): string\n {\n if (! ($this->config->store_transcript)) {\n // If sending transcription to activity toggle is disabled\n return '';\n }\n\n return $this->transcriptionService\n ->findTranscriptionByActivity($activity)\n ->map(static function (array $transcriptionSegment): string {\n return $transcriptionSegment['formattedStartsAt'] . ' | ' . $transcriptionSegment['transcript'];\n })\n ->implode(PHP_EOL);\n }\n\n /**\n * Find related Salesforce event based on activity data\n *\n * @return array<string>\n */\n public function fetchRelatedActivity(Activity $activity): array\n {\n $this->logger->info('[Salesforce] Searching for related activity', [\n 'activityId' => $activity->getUuid(),\n 'ownerId' => $this->profile?->crm_provider_id,\n ]);\n\n $sfEvent = $this->fetchRelatedEvent($activity);\n if (empty($sfEvent)) {\n $this->logger->info('[Salesforce] No related activity found', [\n 'activityId' => $activity->getUuid(),\n 'ownerId' => $this->profile?->crm_provider_id,\n 'account' => $activity->hasAccount()\n ? $activity->getAccount()->getCrmProviderId()\n : null,\n ]);\n\n return [];\n }\n\n return $sfEvent;\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n if ($activity->isTypeConference() === false) {\n return null;\n }\n\n if ($activity->hasActualStartTime() === false && $activity->hasScheduledStartTime() === false) {\n return null;\n }\n\n if (! $activity->hasProspect()) {\n $this->logger->info('[Salesforce] Skip look up, Activity not linked to Lead, Contact or Account', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n $playbook = $this->getPlaybook($activity->getUser());\n if ($playbook !== null && $playbook->getActivityType() === Playbook::ACTIVITY_TYPE_TASK) {\n $this->logger->info('[Salesforce] Skip auto-sync for task-based playbook', [\n 'activityUuid' => $activity->getUuid(),\n 'playbookId' => $playbook->getId(),\n 'playbookType' => $playbook->getActivityType(),\n ]);\n\n return null;\n }\n\n try {\n $sfEvent = $this->fetchRelatedActivity($activity);\n if (empty($sfEvent)) {\n return null;\n }\n\n [$activityField, $activityType] = $this->resolveActivityTypeFromEvent($activity, $sfEvent);\n\n $this->logger->info('[Salesforce] Found related activity', [\n 'activityId' => $activity->getUuid(),\n 'sfEvent' => $sfEvent['Id'],\n 'activityFieldName' => $activityField,\n 'crmActivityType' => ($activityField !== null && isset($sfEvent[$activityField]))\n ? $sfEvent[$activityField]\n : null,\n 'activityType' => $activityType,\n ]);\n\n $userId = $this->findRelatedActivityUserId($activity, $sfEvent);\n\n if ($activity->getUserId() !== $userId) {\n $this->logger->info('[Salesforce] Updating meeting owner', [\n 'activityId' => $activity->getUuid(),\n 'oldUserId' => $activity->getUserId(),\n 'newUserId' => $userId,\n ]);\n }\n\n $this->updateSfEventDescription($activity, $sfEvent);\n\n $activity->update([\n 'user_id' => $userId,\n 'crm_provider_id' => $sfEvent['Id'],\n 'playbook_category_id' => $activityType->id ?? $activity->getCategory()?->getId(),\n ]);\n\n $this->logger->info('[Salesforce] Activity updated', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return $activity;\n } catch (\\Exception $exception) {\n \\Sentry::captureException($exception);\n\n throw $exception;\n }\n }\n\n /**\n * @param array<string, mixed> $sfEvent\n *\n * @return array{0: string|null, 1: mixed}\n */\n private function resolveActivityTypeFromEvent(Activity $activity, array $sfEvent): array\n {\n $activityField = $this->getActivityFieldName($activity);\n $activityType = null;\n\n if ($activityField !== null && ! empty($sfEvent[$activityField])) {\n $playbook = $this->getPlaybook($activity->getUser());\n $activityType = $this->getPlaybookCategory($playbook, strval($sfEvent[$activityField]));\n }\n\n return [$activityField, $activityType];\n }\n\n /**\n * @param array<string> $sfEvent\n */\n private function findRelatedActivityUserId(Activity $activity, array $sfEvent): int\n {\n $userId = $activity->getUserId();\n\n if (empty($sfEvent['OwnerId']) === false) {\n $profile = $this\n ->config\n ->profiles()\n ->where('crm_provider_id', $sfEvent['OwnerId'])\n ->get()\n ->filter(static function (Profile $profile) use ($activity): bool {\n if (! $activity->isTypeConference()) {\n return ! empty($profile->user) ? $profile->user->isStatusActive() : false;\n }\n\n $participants = $activity->getParticipants();\n\n return ! empty($profile->user)\n ? $profile->user->isStatusActive()\n && $profile->user->hasPermission(PermissionEnum::RECORD_MEETING)\n && $participants->contains('user_id', $profile->user_id)\n : false;\n })\n ->first();\n\n if ($profile) {\n $userId = $profile->user_id;\n }\n }\n\n return $userId;\n }\n\n /**\n * @param array<string, mixed> $sfEvent\n */\n private function updateSfEventDescription(Activity $activity, array $sfEvent): void\n {\n try {\n if (str_contains($sfEvent['Description'], $activity->id_string)) {\n return;\n }\n\n $payload = [\n 'Description' => $sfEvent['Description']\n . PHP_EOL\n . PHP_EOL\n . (new DecorateActivity())->generateDescription($activity),\n ];\n\n $this->logger->info('[Salesforce] Update record', [\n 'activityId' => $activity->getUuid(),\n 'sfEvent' => $sfEvent['Id'],\n 'payload' => $payload,\n ]);\n\n $payload = array_merge(\n $payload,\n $this->payloadBuilder->fetchCustomFieldData($activity, Field::OBJECT_EVENT)\n );\n\n $this->updateRecord('Event', $sfEvent['Id'], $payload);\n } catch (\\Exception) {\n $this->logger->error('[Salesforce] Failed to update record', [\n 'activityUuid' => $activity->getUuid(),\n 'sfEvent' => $sfEvent['Id'],\n ]);\n }\n }\n\n /**\n * Returns the most recently modified Event within time range (if any).\n *\n * @return array|null An Event record from Salesforce.\n */\n private function fetchRelatedEvent(Activity $activity): ?array\n {\n $ownerId = $this->profile?->crm_provider_id;\n if ($ownerId === null) {\n return [];\n }\n\n /** @var ?Carbon $from */\n /** @var ?Carbon $to */\n [$from, $to] = $this->getFromToDates($activity);\n\n try {\n $whoId = null;\n $hasWho = $activity->lead_id || $activity->contact_id;\n if ($hasWho) {\n $whoId = $activity->hasLead()\n ? $activity->getLead()->crm_provider_id\n : $activity->getContact()->crm_provider_id;\n }\n\n if ($hasWho === false && $activity->account_id === null) {\n return null;\n }\n\n $query = $this->buildFetchRelatedEventQuery($activity);\n\n $objects = $this->queryHandler->query($query, [\n 'ownerId' => $ownerId,\n 'whoId' => $whoId,\n 'whatId' => $activity->hasOpportunity() ? $activity->getOpportunity()->crm_provider_id : null,\n 'accountId' => $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null,\n 'from' => $from?->format('Y-m-d\\TH:i:s\\Z'),\n 'to' => $to?->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($objects as $object) {\n return $object;\n }\n } catch (NoResultsException $e) {\n return [];\n }\n\n return [];\n }\n\n private function getFromToDates(Activity $activity): array\n {\n $from = null;\n $to = null;\n\n /** @var ?CalendarEvent $calendarEvent */\n $calendarEvent = $activity->calendarEvent()->first();\n if ($calendarEvent !== null) {\n $from = $calendarEvent->getStartTime();\n $to = $calendarEvent->getEndTime();\n }\n\n // For non-calendar imported activities\n // Also double check if calendar event dates could be null?\n // If null use what we've got so far\n if ($from === null || $to === null) {\n $from = $activity->hasScheduledStartTime()\n ? $activity->getScheduledStartTime()\n : $activity->getActualStartTime();\n $to = $activity->hasScheduledEndTime()\n ? $activity->getScheduledEndTime()->addMinutes(15)\n : $activity->getActualEndTime();\n }\n\n return [$from, $to];\n }\n\n /**\n * Determines the appropriate activity field name for querying Salesforce events.\n *\n * This method follows a hierarchy to determine the field name:\n * 1. Uses the playbook's activity field if it exists and is in the profile's accessible fields\n * 2. Falls back to the default activity field if the profile has no event fields configured\n * 3. Returns null if no suitable field is found\n *\n * @param Activity $activity The activity to determine the field for\n *\n * @return string|null The field name to use in queries, or null if none is available\n */\n private function getActivityFieldName(Activity $activity): ?string\n {\n if ($this->profile === null) {\n $this->logger->warning('[Salesforce] Cannot determine activity field - profile not found', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n $profileEventFields = $this->profile->getFieldsAsArray('event');\n\n if (empty($profileEventFields)) {\n $defaultActivityField = $this->getDefaultActivityField(Field::OBJECT_EVENT);\n $defaultFieldName = $defaultActivityField?->getAttribute('crm_provider_id');\n // Profile not yet synced — fall back to the default activity field.\n // There is a small chance that the profile won't have Default Activity Type field access\n // in which case the query will fail.\n // This is however an edge case and should be reviewed for profile sync issues.\n Sentry::withScope(function (\\Sentry\\State\\Scope $scope) use ($defaultFieldName): void {\n $scope->setContext('details', [\n 'profileId' => $this->profile->id,\n 'defaultField' => $defaultFieldName,\n ]);\n Sentry::captureMessage(\n '[Salesforce] Profile event fields empty, falling back to default activity field.',\n \\Sentry\\Severity::warning()\n );\n });\n\n return $defaultFieldName;\n }\n\n $playbook = $this->getPlaybook($activity->getUser());\n\n if (! is_null($playbook) && ! is_null($playbook->getActivityField())) {\n $playbookFieldName = $playbook->getActivityField()->getAttribute('crm_provider_id');\n\n if (in_array($playbookFieldName, $profileEventFields, true)) {\n return $playbookFieldName;\n }\n\n $this->logger->warning('[Salesforce] Playbook activity field not found in profile fields', [\n 'activityId' => $activity->getUuid(),\n 'playbookField' => $playbookFieldName,\n 'profileId' => $this->profile->id,\n ]);\n }\n\n return null;\n }\n\n private function buildFetchRelatedEventQuery(Activity $activity): string\n {\n $hasWho = $activity->lead_id || $activity->contact_id;\n\n $activityFieldName = $this->getActivityFieldName($activity);\n $fields = array_filter(['Id', 'Description', 'OwnerId', $activityFieldName]);\n\n $ownerCondition = '(OwnerId = :ownerId OR CreatedById = :ownerId)';\n\n $query = '\n SELECT ' . implode(',', $fields) . '\n FROM Event\n WHERE ' . $ownerCondition . '\n AND IsArchived = false\n AND IsAllDayEvent = false\n AND StartDateTime >= :from\n AND EndDateTime <= :to\n AND (';\n\n $operator = '';\n if ($activity->account_id) {\n // This covers events tied to a related contact or opportunity too.\n $query .= 'AccountId = :accountId';\n\n $operator = ' OR ';\n }\n\n if ($hasWho) {\n $query .= $operator . 'WhoId = :whoId';\n\n // If we are also going to check on a specific opportunity, set that up.\n if ($activity->opportunity_id) {\n $query .= ' OR WhatId = :whatId';\n }\n }\n\n $query .= ') ORDER BY LastModifiedDate DESC';\n\n return $query;\n }\n\n public function fetchProspect(array $task): array\n {\n $lead = $account = $opportunity = $contact = $stage = $countryCode = null;\n $externalId = $task['WhoId'] ?? null;\n\n // Lead or Contact\n if ($externalId) {\n try {\n [$lead, $account, $opportunity, $contact, $stage, $countryCode] = $this->parseRecords($externalId);\n } catch (\\InvalidArgumentException $exception) {\n // Invalid object type.\n }\n }\n\n // If we happen to know the opportunity or account from the Task, figure that out.\n if (empty($task['WhatId']) === false) {\n // WhatId could be either Account ID or Opportunity ID.\n // If WhatId is Opportunity ID, get the opportunity and stage from the CRM.\n try {\n [, $account, $opportunity, , $stage, ] = $this->parseRecords($task['WhatId']);\n } catch (\\InvalidArgumentException $exception) {\n // Invalid object type.\n }\n }\n\n return [$lead, $account, $opportunity, $contact, $stage, $countryCode];\n }\n\n /**\n * Save activity transcription summary as note\n */\n public function saveTranscriptionSummaryAsNote(\n ActivityContract $activity,\n string $title,\n string $body,\n ?string $objectId,\n ?NoteObject $noteObject = null,\n ): ?string {\n return $this->saveNote($title, $body, (string) $objectId);\n }\n\n public function getObjectByFilterConditions(string $objectType, array $fields, array $filters): ?array\n {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $query = $queryBuilder->buildObjectSearchQuery($objectType, $fields, $filters);\n\n try {\n $objects = $this->queryHandler->query($query, $filters);\n if ($objects->count() === 1) {\n return $objects->current();\n }\n } catch (\\Exception $e) {\n $this->logger->info('[Salesforce] Failed to execute query', [\n 'query' => $query,\n 'error' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n private function getCustomProfileRules(TeamRepository $teamRepository): array\n {\n $teamSettings = $teamRepository->getTeamSetting($this->team, 'custom_profile_validation');\n\n if ($teamSettings instanceof TeamSettings && $teamSettings->getValueType() === 'array') {\n $customRules = json_decode($teamSettings->getValue(), true);\n if (is_array($customRules)) {\n return $customRules;\n }\n }\n\n return [];\n }\n\n private function customProfileValidation(array $crmUser, array $customRules): bool\n {\n foreach ($customRules as $customRule) {\n if ($crmUser[$customRule['field']] !== $customRule['value']) {\n return false;\n }\n }\n\n return true;\n }\n\n /**\n * When syncing Contact / Lead / Account / Opportunity / Stage crm entities,\n * validate and restore locally trashed objects,\n * before updating them. Objects are identified by CrmProviderId\n */\n private function restoreAnyTrashedEntity(HasMany $targetEntity, string $crmProviderId): void\n {\n $recordExists = $targetEntity->withTrashed()->where(['crm_provider_id' => $crmProviderId])->first();\n if ($recordExists && $recordExists->trashed()) {\n $recordExists->restore();\n }\n }\n\n #[\\Override] public function supportsNotes(): bool\n {\n return true;\n }\n\n private function getOwnerProfile(?string $ownerId): ?Profile\n {\n if ($ownerId === null) {\n return null;\n }\n\n return $this->config->profiles()\n ->where('crm_provider_id', $ownerId)\n ->first();\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Help","depth":1,"on_screen":true,"help_text":"Show help contents","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Cancel","depth":1,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Commit","depth":1,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Commit","depth":2,"on_screen":true,"help_text":"Show drop-down menu (⌥⇧⏎)","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Commit Changes","depth":1,"on_screen":true,"role_description":"text"}]...
|
-4667806593135088920
|
-7851939513083163707
|
typing_pause
|
accessibility
|
NULL
|
Diff
Changelist:
Changelist:
Changes
Show Diff
Rol Diff
Changelist:
Changelist:
Changes
Show Diff
Rollback...
Refresh
Group By
Expand All
Collapse All
1 modified
JY-20
Commit Message
Commit Message History
Git
Author:
Author:
Amend commit
Sign-off commit
Create extra commit with file movements
Commit Checks
Update copyright
Reformat code
Rearrange code
Optimize imports
Cleanup
Choose profile
Check malicious dependencies
Run external formatter
Configure
Perform SonarQube for IDE analysis
Find unused view files
Run Git hooks
Advanced Commit Checks
Analyze code
Choose profile
Check TODO
Configure
After Commit
Upload files to:
Upload files to:
<None>
Always use selected server or group of servers
Previous Difference
Next Difference
Jump to Source
Compare Previous File
Compare Next File
Go to Changed File…
Side-by-side viewer
Do not ignore
Highlight words
Collapse Unchanged Fragments
Synchronize Scrolling
Disable Editing
Settings
Help
4 differences
1aee7aad
<?php
namespace Jiminny\Services\Crm\Salesforce;
use Carbon\Carbon;
use Exception;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Events\Dispatcher;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Jiminny\Component\Country\CountriesMap;
use Jiminny\Contracts\Acl\PermissionEnum;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Contracts\Services\Crm\FetchRelatedActivityInterface;
use Jiminny\Contracts\Services\Crm\ImportsBusinessProcessesInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\SalesforceBatchSyncInterface;
use Jiminny\Contracts\Services\Crm\Provider\SalesforceInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\RemoteNoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SearchTaskInterface;
use Jiminny\Contracts\Services\Crm\SendSummaryToCrmInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SupportsObjectTypeParseInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmProfileRecordTypesInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Enums\CrmObject;
use Jiminny\Events\Activities\Crm\LeadConverted;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpBadRequestException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Exceptions\NoResultsException;
use Jiminny\Exceptions\ServiceUnavailableException;
use Jiminny\Jobs\Crm\NoteObject;
use Jiminny\Jobs\Crm\MatchActivitiesToNewOpportunity;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Calendar\CalendarEvent;
use Jiminny\Models\Contact;
use Jiminny\Models\Contracts\ActivityContract;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Models\Crm\ContactRole;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Crm\RecordType;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Playbook;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\TeamSettings;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\ContactRoleRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\Crm\RecordTypeFieldValuesRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Helpers\ArrayIterator;
use Jiminny\Services\Crm\MatchDomainByEmailInterface;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Services\Crm\ResolveCompanyNameByEmailTrait;
use Jiminny\Services\Crm\Salesforce\Fields\FieldHelper;
use Jiminny\Services\Crm\Salesforce\Fields\FieldTypeConverter;
use Jiminny\Services\Crm\Salesforce\Fields\ValueNormalizer;
use Jiminny\Services\Crm\Salesforce\ServiceTraits\FollowupActivityTrait;
use Jiminny\Services\Crm\Salesforce\ServiceTraits\LogActivityTrait;
use Jiminny\Services\Crm\Salesforce\ServiceTraits\RecordManipulationsTrait;
use Jiminny\Services\Crm\Salesforce\ServiceTraits\SyncFieldsTrait;
use Jiminny\Utils\CurrencyFormatter;
use Jiminny\Utils\StringUtil;
use Ramsey\Uuid\Uuid;
use Sentry\Laravel\Facade as Sentry;
class Service extends BaseService implements
SalesforceInterface,
SalesforceBatchSyncInterface,
SyncCrmEntitiesInterface,
SyncCrmProfileRecordTypesInterface,
ImportsBusinessProcessesInterface,
RemoteEntityManipulationInterface,
FetchRelatedActivityInterface,
SendSummaryToCrmInterface,
MatchDomainByEmailInterface,
SearchTaskInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
SupportsObjectTypeParseInterface,
RemoteNoteEntityManipulationInterface,
VerifyTaskExistsInterface
{
use ResolveCompanyNameByEmailTrait;
use SyncFieldsTrait;
use DeleteObjectsTrait;
use RecordManipulationsTrait;
use ServiceTraits\BatchSyncTrait;
use FollowupActivityTrait;
use LogActivityTrait;
/**
* Note Body Limit for the Old Note-Taking Tool
*
* @var int
*/
private const int CLASSIC_NOTE_MAX_LENGTH = 32000;
/**
* Note Content Limit for the New Notes
*
* @var int
*/
private const int ENHANCED_NOTE_MAX_LENGTH = 50000000;
private const string INSTALLED_PACKAGE_ID = '033Tw0000007bKbIAI';
private const int CACHE_TTL = 600;
private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day - 86400
/**
* @var Client
*/
protected $client;
protected PayloadBuilder $payloadBuilder;
protected QueryHandler $queryHandler;
private OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
public function __construct(
Client $client,
PayloadBuilder $payloadBuilder,
protected Dispatcher $eventDispatcher,
private readonly CountriesMap $countriesMap,
private readonly ProspectPhotoPathService $prospectPhotoPathService,
) {
parent::__construct();
$this->client = $client;
$this->payloadBuilder = $payloadBuilder;
$this->queryHandler = app(QueryHandler::class, [
'client' => $this->client,
'logger' => $this->logger,
]);
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
}
public function getDisplayName(): string
{
return 'Salesforce';
}
public function getJobDelay(): int
{
return 1;
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
return $user->getSocialAccount(SocialAccount::PROVIDER_SALESFORCE);
}
public function verifyTaskExists(Activity $activity): bool
{
$crmProviderId = $activity->getCrmProviderId();
$cacheKey = "crm_task_exists:{$this->config->getId()}:$crmProviderId";
return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($activity, $crmProviderId) {
$playbook = $this->getPlaybookFromActivity($activity);
if ($playbook === null) {
$this->logger->warning('[Salesforce] Cannot verify task - no playbook found', [
'activity' => $activity->getId(),
'crm_provider_id' => $crmProviderId,
]);
return false;
}
$objectType = $playbook->getActivityType() === Playbook::ACTIVITY_TYPE_EVENT ? 'Event' : 'Task';
try {
$record = $this->getRecord($objectType, $crmProviderId, ['Id', 'IsDeleted']);
return ! empty($record) && ($record['IsDeleted'] ?? false) === false;
} catch (HttpNotFoundException|HttpBadRequestException) {
$this->logger->info('[Salesforce] Activity record not found during verification', [
'activity' => $activity->getId(),
'object_type' => $objectType,
'crm_provider_id' => $crmProviderId,
'config_id' => $this->config->getId(),
]);
return false;
}
});
}
public function query(string $queryToRun, array $parameters = []): QueryIterator
{
// Due to poorly designed external calls, this method cannot be entirely removed
return $this->queryHandler->query($queryToRun, $parameters);
}
/*=========== Organization Information ===============*/
/**
* Get a list of all the API Versions for the instance.
*
* @throws CrmException
*
* @return mixed
*
*/
public function getApiVersions()
{
$url = $this->config->crm_base_url . '/services/data';
$response = $this->client->get($url);
return json_decode($response->getBody(), true);
}
/**
* Gets the valid recordTypes for a given Salesforce Object via the describe API.
*/
private function getRecordTypes(string $crmObject): array
{
$url = $this->client->getObjectsUrl() . $crmObject . '/describe';
$response = $this->client->get($url);
$jsonResponse = json_decode($response->getBody(), true);
$fields = [];
foreach ($jsonResponse['recordTypeInfos'] as $row) {
$fields[] = ['recordTypeId' => $row['recordTypeId'], 'default' => $row['defaultRecordTypeMapping']];
}
return $fields;
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return ValueNormalizer::normalize($fieldType, $fieldValue, $internal);
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
$defaultFields = ($activityType === Playbook::ACTIVITY_TYPE_TASK)
? FieldDefinitions::defaultTaskFields()
: FieldDefinitions::defaultEventFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
// Setup the activity field as the default Type.
/** @var Field $activityField */
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'Type',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK, Playbook::ACTIVITY_TYPE_EVENT];
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldFilter = ($activityType === Playbook::ACTIVITY_TYPE_TASK)
? FieldDefinitions::taskFollowupFieldsFilter()
: FieldDefinitions::eventFollowupFieldsFilter();
foreach ($fieldFilter as $eachFilter) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $eachFilter);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
/**
* This one is now called only when ImportActivityTypes is triggered or SyncFieldMetadata executed manually
* Regular sync now uses SharedSyncFieldsTrait -> syncSingleObjectType
* Needs to be replaced later on
*/
public function syncField(Field $field): void
{
try {
if (FieldHelper::isCustomField($field)) {
$query = '
SELECT
Id, Metadata, TableEnumOrId
FROM
CustomField
WHERE
DeveloperName = :fieldName
AND
TableEnumOrId = :fieldType
AND
NamespacePrefix = :namespacePrefix';
// We need to constrain the field lookup to the object, in case it's used in multiple places.
$objectType = \in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true)
? 'activity'
: $field->object_type;
$sfFields = $this->queryHandler->metadata($query, [
'fieldName' => substr($field->crm_provider_id, 0, -\strlen('__c')),
'fieldType' => ucfirst($objectType),
// This is used to ensure we only consider the field within the org, not installed packages.
'namespacePrefix' => 'null',
]);
// There is always 1 result at this point.
$sfField = $sfFields->current();
// Sync field metadata.
$metadata = $sfField['Metadata'];
$field->description = mb_strimwidth($metadata['description'] ?? '', 0, 191);
$field->label = mb_strimwidth($metadata['label'] ?? '', 0, Field::LABEL_MAX_LENGTH);
$field->type = $this->convertFieldType($metadata['type'], $field->getEntityName());
$field->is_mandatory = ($metadata['required'] === true);
$field->length = $metadata['length'];
$field->default_value = mb_strimwidth(trim($metadata['defaultValue'] ?? '', '"'), 0, 191);
$field->save();
} else {
$query = '
SELECT
Id, DataType, DeveloperName, Label, Length, Description
FROM
FieldDefinition
WHERE
DurableId = :entityName';
$entityName = $field->getEntityName();
$sfFields = $this->queryHandler->metadata($query, [
'entityName' => $entityName,
]);
// There is always 1 result at this point.
$sfField = $sfFields->current();
$convertedType = $this->convertFieldType($sfField['DataType'], $entityName);
$label = mb_strimwidth($sfField['Label'], 0, Field::LABEL_MAX_LENGTH);
if ($field->isBusinessType()) {
$label = 'Opportunity Type';
}
$field->description = mb_strimwidth($sfField['Description'], 0, Field::DESCRIPTION_MAX_LENGTH);
$field->label = $label;
$field->type = $convertedType;
$field->length = $sfField['Length'];
$field->save();
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
}
private function convertFieldType(string $from, ?string $entityName = null): string
{
$converter = new FieldTypeConverter();
return $converter->convert($from, $entityName);
}
/**
* @inheritdoc
*/
public function importPicklistValues(Field $field): array
{
$values = [];
$fieldValues = [];
try {
if (FieldHelper::isCustomField($field)) {
$query = '
SELECT
Id, Metadata, TableEnumOrId
FROM
CustomField
WHERE
DeveloperName = :fieldName
AND
TableEnumOrId = :fieldType
AND
NamespacePrefix = :namespacePrefix';
// We need to constrain the field lookup to the object, in case it's used in multiple places.
$objectType = \in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true) ?
'activity' : $field->object_type;
$sfFields = $this->queryHandler->metadata($query, [
'fieldName' => substr($field->crm_provider_id, 0, -\strlen('__c')),
'fieldType' => ucfirst($objectType),
// This is used to ensure we only consider the field within the org, not installed packages.
'namespacePrefix' => 'null',
]);
// There is always 1 result at this point.
$sfField = $sfFields->current();
$valueSet = $sfField['Metadata']['valueSet'];
if ($valueSet['valueSetName'] === null) {
// Local picklist values can be obtained easily.
$picklistValues = $valueSet['valueSetDefinition']['value'];
} else {
// But for some fields, we just get the Global Value Picklist pointer so need to do more work.
$picklistValues = $this->importGlobalValuePicklistValues($valueSet['valueSetName']);
}
// Import all active values.
foreach ($picklistValues as $i => $sfFieldValue) {
// Setup default value.
if ($sfFieldValue['default']) {
$field->update(['default_value' => $sfFieldValue['valueName']]);
}
// This comes through as null if active (lol).
if ($sfFieldValue['isActive'] !== false) {
$values[] = [
'value' => $sfFieldValue['valueName'],
'label' => $sfFieldValue['valueName'],
'sequence' => $i,
'is_default' => $sfFieldValue['default'],
];
}
}
} else {
$objectFields = $this->getObjectFields($field->object_type);
$fieldId = $field->crm_provider_id;
// Only work with our field of interest.
$objectField = array_filter($objectFields, function ($item) use ($fieldId) {
return $item['name'] === $fieldId;
});
$objectField = array_shift($objectField);
if (empty($objectField['picklistValues']) === false) {
foreach ($objectField['picklistValues'] as $i => $sfFieldValue) {
// Skip inactive values.
if ($sfFieldValue['active'] === false) {
continue;
}
// Setup default value.
if ($sfFieldValue['defaultValue']) {
$field->update(['default_value' => $sfFieldValue['value']]);
}
$values[] = [
'value' => $sfFieldValue['value'],
'label' => $sfFieldValue['label'],
'sequence' => $i,
'is_default' => $sfFieldValue['defaultValue'],
];
}
}
}
$fieldsToPurge = $field->values()->get()->pluck('value')->toArray();
foreach ($values as $value) {
$value['value'] = substr($value['value'] ?? '', 0, 255);
$fieldValues[] = $field->values()->updateOrCreate([
'value' => $value['value'],
], $value);
// Remove this value from the ones we are going to purge.
if (($key = array_search($value['value'], $fieldsToPurge, true)) !== false) {
unset($fieldsToPurge[$key]);
}
}
// Delete the old values that are no longer used.
// Get IDs of the values to be deleted
$valuesToDelete = $field->values()->whereIn('value', $fieldsToPurge);
$valuesToDeleteIds = $valuesToDelete->pluck('id');
if (! $valuesToDeleteIds->isEmpty()) {
$recordTypeFieldValuesRepository = app(RecordTypeFieldValuesRepository::class);
$recordTypeFieldValuesRepository->deleteForCrmFieldValueIds($valuesToDeleteIds->toArray());
// Now safely delete from crm_field_values
$valuesToDelete->delete();
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
return $fieldValues;
}
/**
* Gets values from Global Value Picklists.
*/
private function importGlobalValuePicklistValues(string $picklistName): array
{
$query = '
SELECT
Metadata
FROM
GlobalValueSet
WHERE
DeveloperName = :picklistName
LIMIT 1';
try {
$sfValues = $this->queryHandler->metadata($query, [
'picklistName' => $picklistName,
]);
// There is always 1 result at this point.
$sfValue = $sfValues->current();
return $sfValue['Metadata']['customValue'];
} catch (NoResultsException $noResultsException) {
// Nothing returned.
return [];
}
}
/**
* @inheritdoc
*/
public function syncProfileRecordTypes(): void
{
$objectTypes = [
'lead',
'account',
'contact',
'opportunity',
'task',
'event',
];
foreach ($objectTypes as $objectType) {
try {
$crmRecordTypes = $this->getRecordTypes(ucfirst($objectType));
foreach ($crmRecordTypes as $crmRecordType) {
// If the record type is default and not the Master type, set this.
if ($crmRecordType['default'] && $crmRecordType['recordTypeId'] !== '012000000000000AAA') {
$recordType = $this->config->recordTypes()
->where('crm_provider_id', $crmRecordType['recordTypeId'])
->first();
if ($recordType) {
$this->profile->{$objectType . '_record_type_id'} = $recordType->id;
}
}
}
} catch (HttpNotFoundException $exception) {
Log::error('No access to ' . $objectType . ' object, skipping...');
// XXX: should we log this fact somewhere?
continue;
}
}
if ($this->profile->isDirty()) {
$this->profile->save();
}
}
/**
* Gets business processes.
*/
public function importBusinessProcesses(): void
{
$query = '
SELECT
Id, IsActive, Name, TableEnumOrId
FROM
BusinessProcess
WHERE
TableEnumOrId IN (\'Lead\',\'Opportunity\')';
try {
$sfProcesses = $this->queryHandler->query($query);
// Upsert all processes for the team.
foreach ($sfProcesses as $sfProcess) {
/** @var BusinessProcess $businessProcess */
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => $sfProcess['Id'],
], [
'team_id' => $this->team->id,
'name' => $sfProcess['Name'],
'type' => $sfProcess['TableEnumOrId'] === 'Lead' ? 'lead' : 'opportunity',
'is_selectable' => $sfProcess['IsActive'],
]);
$this->importBusinessProcessStages($businessProcess);
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
}
/**
* Gets business process stages.
*/
private function importBusinessProcessStages(BusinessProcess $businessProcess): void
{
$query = '
SELECT
Metadata
FROM
BusinessProcess
WHERE
Id = :processId';
try {
$stages = [];
$sfProcessStages = $this->queryHandler->metadata($query, [
'processId' => $businessProcess->crm_provider_id,
]);
// There is always 1 result at this point.
$sfProcessStage = $sfProcessStages->current();
// Upsert all processes for the team.
foreach ($sfProcessStage['Metadata']['values'] as $sfProcessStage) {
$sanitizedName = urldecode($sfProcessStage['valueName']); // Must decode: "%2C" becomes "," etc.
$stage = $businessProcess->crm->stages()
// This MUST match on label because this API doesn't use API Name.
->where('label', $sanitizedName)
->where('type', $businessProcess->type)
->where('is_selectable', 1)
->first();
if ($stage) {
$stages[] = $stage->id;
}
}
$businessProcess->stages()->sync($stages);
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
}
/**
* Gets record types.
*/
public function importRecordTypes(): void
{
$query = '
SELECT
Id, IsActive, Name, BusinessProcessId, SobjectType
FROM
RecordType';
try {
$sfRecordTypes = $this->queryHandler->query($query);
// Upsert all record types for the process.
foreach ($sfRecordTypes as $sfRecordType) {
$businessProcess = null;
if ($sfRecordType['BusinessProcessId']) {
$businessProcess = $this->config->businessProcesses()
->where('crm_provider_id', $sfRecordType['BusinessProcessId'])
->first();
}
/** @var RecordType $recordType */
$recordType = $this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => $sfRecordType['Id'],
], [
'team_id' => $this->team->id,
'type' => mb_strtolower($sfRecordType['SobjectType']),
'name' => $sfRecordType['Name'],
'is_selectable' => $sfRecordType['IsActive'],
'business_process_id' => $businessProcess->id ?? null,
]);
$this->importRecordTypeFieldValues($recordType);
}
} catch (NoResultsException $noResultsException) {
// Do nothing.
}
}
/**
* Import record type - field value mappings. This only works for standard fields.
*/
private function importRecordTypeFieldValues(RecordType $recordType): void
{
try {
$query = '
SELECT
Metadata
FROM
RecordType
WHERE
Id = :recordTypeId';
$sfFields = $this->queryHandler->metadata($query, [
'recordTypeId' => $recordType->crm_provider_id,
]);
// There is always 1 result at this point.
$sfField = $sfFields->current();
// Sync field metadata.
$picklists = $sfField['Metadata']['picklistValues'];
foreach ($picklists as $picklist) {
$field = $this->config->fields()->where([
'type' => Field::TYPE_PICKLIST,
'object_type' => $recordType->type,
'crm_provider_id' => $picklist['picklist'],
])->first();
if ($field) {
$fieldValues = [];
foreach ($picklist['values'] as $value) {
// Must decode: "%2C" becomes "," etc.
$fieldValue = $field->values()
->where('value', urldecode($value['valueName']))
->first();
if ($fieldValue) {
$fieldValues[] = $fieldValue->id;
}
}
$recordType->fieldValues()->sync($fieldValues);
}
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
}
/**
* @inheritdoc
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$params = [];
$missingStage = null;
if ($types === null) {
$types = [Stage::TYPE_LEAD, Stage::TYPE_OPPORTUNITY];
}
foreach ($types as $type) {
if ($type === Stage::TYPE_LEAD) {
$query = '
SELECT
Id, ApiName, MasterLabel, SortOrder
FROM
LeadStatus';
} else {
$query = '
SELECT
Id, ApiName, MasterLabel, IsActive, SortOrder, DefaultProbability
FROM
OpportunityStage';
}
if ($missingStageName) {
$escapedStageName = ValueNormalizer::replaceQueryWithStringLiterals($missingStageName);
$query .= ' WHERE ApiName = :stageName';
$params = [
'stageName' => $escapedStageName,
];
}
try {
$sfStages = $this->queryHandler->query($query, $params);
} catch (NoResultsException $exception) {
$sfStages = [];
}
$missingStage = null;
// Upsert all stages for the team.
foreach ($sfStages as $sfStage) {
$selectable = true;
if (array_key_exists('IsActive', $sfStage)) {
$selectable = $sfStage['IsActive'];
}
$this->restoreAnyTrashedEntity($this->config->stages(), $sfStage['Id']);
$stage = $this->config->stages()->updateOrCreate([
'crm_provider_id' => $sfStage['Id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($sfStage['ApiName'], 0, 50),
'label' => mb_strimwidth($sfStage['MasterLabel'], 0, 191),
'type' => $type,
'sequence' => $sfStage['SortOrder'] ?? 0,
'is_selectable' => $selectable,
'probability' => $sfStage['DefaultProbability'] ?? null,
]);
if ($missingStageName && $missingStageName === $sfStage['ApiName']) {
$missingStage = $stage;
}
}
if ($missingStageName && $missingStage === null) {
// If they requested a stage that still doesn't exist, it must be inactive so lazy create it.
$missingStage = $this->config->stages()->create([
'crm_provider_id' => Uuid::uuid4(),
'team_id' => $this->team->id,
'name' => mb_strimwidth($missingStageName, 0, 50),
'label' => mb_strimwidth($missingStageName, 0, 191),
'type' => $type,
'sequence' => 0,
'is_selectable' => 0,
]);
}
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int
{
$syncCount = 0;
$fields = $this->getAllFieldsAsArray('lead');
if (\in_array('Id', $fields, true) === false) {
return $syncCount;
}
$query = '
SELECT ' . rtrim(implode(',', $fields), ',') . '
FROM Lead
WHERE LastModifiedDate > :since
ORDER BY LastModifiedDate ASC';
try {
$sfLeads = $this->queryHandler->query($query, [
'since' => $since->format('Y-m-d\TH:i:s\Z'),
]);
foreach ($sfLeads as $sfLead) {
// Only sync if previously imported.
if ($this->hasLead($sfLead['Id'])) {
$this->importLead($sfLead);
$syncCount++;
}
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
$this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::LEAD);
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncLead(string $crmId): ?Lead
{
$fields = $this->getAllFieldsAsArray('lead');
$sfLead = $this->getRecord('Lead', $crmId, $fields);
return $this->importLead($sfLead);
}
private function importLead($crmData): ?Lead
{
/** @var ?Stage $stage */
$stage = null;
if (isset($crmData['Status'])) {
// Get the current stage.
$stage = $this->config
->stages()
->where('name', $crmData['Status'])
->where('type', Stage::TYPE_LEAD)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_LEAD], $crmData['Status']);
}
}
// If we have no way of importing this, just return null :(
if ($stage === null) {
return null;
}
$countryCode = $crmData['CountryCode'] ?? null;
// Salesforce allows custom "countries" to be created. Disregard these.
if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {
$countryCode = null;
}
// If we have no country code, try to parse it from the country name.
if ($countryCode === null && empty($crmData['Country']) !== false) {
$countryCode = $this->convertCountryNameToCode($crmData['Country']);
}
// Trim to our width and attempt to parse it.
$number = mb_strimwidth($crmData['Phone'] ?? '', 0, 25);
$parsedNumber = parsePhoneNumber($countryCode, $number);
$mobilePhone = null;
if (empty($crmData['MobilePhone']) === false) {
// Trim to our width and attempt to parse it.
$number = mb_strimwidth($crmData['MobilePhone'], 0, 25);
$mobilePhone = phone_e164($countryCode, $number);
}
$convertedDate = null;
$convertedAccount = null;
$convertedOpportunity = null;
$convertedContact = null;
if ($crmData['IsConverted'] == 'true') {
$convertedDate = $crmData['ConvertedDate'];
if (empty($crmData['ConvertedAccountId']) === false) {
$convertedAccount = $this->config
->accounts()
->where('crm_provider_id', $crmData['ConvertedAccountId'])
->first();
if ($convertedAccount === null) {
try {
$convertedAccount = $this->syncAccount($crmData['ConvertedAccountId']);
} catch (HttpNotFoundException $exception) {
// Probably the user has no permissions to access the converted data.
}
}
}
if (empty($crmData['ConvertedOpportunityId']) === false) {
$convertedOpportunity = $this->config
->opportunities()
->where('crm_provider_id', $crmData['ConvertedOpportunityId'])
->first();
if ($convertedOpportunity === null) {
try {
$convertedOpportunity = $this->syncOpportunity($crmData['ConvertedOpportunityId']);
} catch (HttpNotFoundException $exception) {
// Probably the user has no permissions to access the converted data.
}
}
}
if (empty($crmData['ConvertedContactId']) === false) {
$convertedContact = $this->team
->crm
->contacts()
->where('crm_provider_id', $crmData['ConvertedContactId'])
->first();
if ($convertedContact === null) {
try {
$convertedContact = $this->syncContact($crmData['ConvertedContactId']);
} catch (HttpNotFoundException $exception) {
// Probably the user has no permissions to access the converted data.
}
}
}
}
if (empty($crmData['Company'])) {
$company = 'Unknown';
} else {
$company = mb_strimwidth($crmData['Company'], 0, 191);
}
$domain = null;
if (empty($crmData['Website']) === false) {
$domain = mb_strimwidth($crmData['Website'], 0, 191);
$domain = StringUtil::resolveDomain($domain);
}
$createdDate = null;
if (empty($crmData['CreatedDate']) === false) {
$createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');
}
$profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);
$data = [
'team_id' => $this->team->id,
'user_id' => $profile?->user_id,
'owner_id' => $crmData['OwnerId'] ?? '',
'company' => $company,
'domain' => $domain,
'name' => $crmData['Name'] ? mb_strimwidth($crmData['Name'], 0, 191) : '',
'title' => $crmData['Title'] ? mb_strimwidth($crmData['Title'], 0, 128) : null,
'email' => $crmData['Email'] ? mb_strimwidth($crmData['Email'], 0, 80) : null,
'phone' => $parsedNumber['phone'],
'ext' => $parsedNumber['ext'] ?? null,
'mobile_phone' => $mobilePhone,
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: $crmData['Id'],
modelType: Lead::class,
fileName: $crmData['Id'],
avatarText: $crmData['Name']
),
'stage_id' => $stage->id,
'record_type_id' => null,
'converted_at' => $convertedDate,
'converted_account_id' => $convertedAccount->id ?? null,
'converted_opportunity_id' => $convertedOpportunity->id ?? null,
'converted_contact_id' => $convertedContact->id ?? null,
'country_code' => $countryCode,
'remotely_created_at' => $createdDate,
];
$this->restoreAnyTrashedEntity($this->config->leads(), $crmData['Id']);
/** @var Lead $lead */
$lead = $this->config->leads()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);
if ($lead->wasChanged('converted_at') && $lead->getConvertedAt() !== null) {
$this->eventDispatcher->dispatch(new LeadConverted($lead));
}
$this->handleObjectDeletion($lead, $crmData);
return $lead;
}
/**
* @inheritdoc
*/
public function syncAccounts(Carbon $since, ?Carbon $to = null): int
{
$syncCount = 0;
$fields = $this->getAllFieldsAsArray('account');
if (\in_array('Id', $fields, true) === false) {
return $syncCount;
}
$query = '
SELECT ' . rtrim(implode(',', $fields), ',') . '
FROM Account
WHERE LastModifiedDate > :since
ORDER BY LastModifiedDate ASC';
try {
$sfAccounts = $this->queryHandler->query($query, [
'since' => $since->format('Y-m-d\TH:i:s\Z'),
]);
foreach ($sfAccounts as $sfAccount) {
// Only sync if previously imported.
if ($this->hasAccount($sfAccount['Id'])) {
$this->importAccount($sfAccount);
$syncCount++;
}
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
$this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::ACCOUNT);
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncAccount(string $crmId): ?Account
{
$fields = $this->getAllFieldsAsArray('account');
if (! in_array('Id', $fields, true)) {
$this->logger->info('[Salesforce] Sync account cancelled. Fields are not available.', [
'crmId' => $crmId,
'userId' => $this->profile->getUserId(),
]);
return null;
}
$sfAccount = $this->getRecord('Account', $crmId, $fields);
return $this->importAccount($sfAccount);
}
private function importAccount($crmData): Account
{
$countryCode = $crmData['BillingCountryCode'] ?? $crmData['ShippingCountryCode'] ?? null;
// Salesforce allows custom "countries" to be created. Disregard these.
if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {
$countryCode = null;
}
// If we have no country code, try to parse it from the country names.
if ($countryCode === null && empty($crmData['BillingCountry']) === false) {
$countryCode = $this->convertCountryNameToCode($crmData['BillingCountry']);
}
if ($countryCode === null && empty($crmData['ShippingCountry']) === false) {
$countryCode = $this->convertCountryNameToCode($crmData['ShippingCountry']);
}
if (empty($crmData['Phone']) === false) {
// Trim to our width and attempt to parse it.
$number = mb_strimwidth($crmData['Phone'], 0, 25);
$parsedNumber = parsePhoneNumber($countryCode, $number);
} else {
$parsedNumber = [];
}
$industry = null;
if (empty($crmData['Industry']) === false) {
$industry = mb_strimwidth($crmData['Industry'], 0, 40);
}
$domain = null;
if (empty($crmData['Website']) === false) {
$domain = mb_strimwidth($crmData['Website'], 0, 191);
$domain = StringUtil::resolveDomain($domain);
}
$createdDate = null;
if (empty($crmData['CreatedDate']) === false) {
$createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');
}
$profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);
$data = [
'team_id' => $this->team->id,
'user_id' => $profile?->user_id,
'owner_id' => $crmData['OwnerId'],
'name' => mb_strimwidth($crmData['Name'], 0, 191),
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: $crmData['Id'],
modelType: Account::class,
fileName: $crmData['Id'],
avatarText: $crmData['Name']
),
'industry' => $industry,
'domain' => $domain,
'phone' => $parsedNumber['phone'] ?? null,
'ext' => $parsedNumber['ext'] ?? null,
'country_code' => $countryCode,
'remotely_created_at' => $createdDate,
];
$this->restoreAnyTrashedEntity($this->config->accounts(), $crmData['Id']);
/** @var Account $account */
$account = $this->config->accounts()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);
$this->handleObjectDeletion($account, $crmData);
return $account;
}
/**
* @inheritdoc
*/
public function syncOpportunities(array $parameters, ?string $strategy = null): int
{
$strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);
$syncCount = 0;
$logParams = $parameters;
$parameters['profile'] = $this->profile;
$logParams['user'] = $this->profile->getUserId();
if (count($strategies) > 1) {
$this->logger->warning('[' . $this->getDisplayName() . '] Multiple sync strategies used', [
'teamId' => $this->team->getUuid(),
'params' => $logParams,
'strategies_count' => count($strategies),
]);
}
foreach ($strategies as $syncStrategy) {
$name = $syncStrategy->getStrategyName();
try {
$sfOpportunities = $syncStrategy->fetchOpportunities($parameters);
$totalRecords = $sfOpportunities->count();
foreach ($sfOpportunities as $sfOpportunity) {
$this->importOpportunity($sfOpportunity);
$syncCount++;
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
$this->logger->warning('[' . $this->getDisplayName() . '] No opportunities found', [
'teamId' => $this->team->getUuid(),
'name' => $name,
'params' => $logParams,
'reason' => $noResultsException->getMessage(),
]);
} catch (CrmException $crmException) {
// Nothing to sync.
$this->logger->warning('[' . $this->getDisplayName() . '] Opportunity sync failed', [
'teamId' => $this->team->getUuid(),
'name' => $name,
'params' => $logParams,
'reason' => $crmException->getMessage(),
]);
}
}
$this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::OPPORTUNITY, ['params' => $logParams]);
// debug to see how if count of opportunities reaches 1000
if ($syncCount >= 1000) {
$this->logger->info(
'[' . $this->getDisplayName() . '] Sync Opportunities - count warning',
[
'team_id' => $this->team->getId(),
'params' => $logParams,
'count' => $syncCount,
'strategies_count' => count($strategies),
'total_records' => $totalRecords ?? null,
]
);
}
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncOpportunity(string $crmId): ?Opportunity
{
$strategy = $this->opportunitySyncStrategyResolver->resolve(
$this->config,
OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY
);
$parameters = [
'profile' => $this->profile,
'crm_id' => $crmId,
];
try {
$sfOpportunity = $strategy->fetchOpportunities($parameters);
} catch (HttpNotFoundException $e) {
$this->logger->info('[' . $this->getDisplayName() . '] Opportunity not found', [
'teamId' => $this->team->id_string,
'crmId' => $crmId,
]);
return null;
} catch (CrmException $crmException) {
$this->logger->info('[' . $this->getDisplayName() . '] Opportunity sync failed', [
'teamId' => $this->team->id_string,
'crmId' => $crmId,
'exception' => $crmException->getMessage(),
]);
return null;
}
if ($sfOpportunity instanceof ArrayIterator) {
return $this->importOpportunity($sfOpportunity->getItems());
}
return $this->importOpportunity($sfOpportunity);
}
/**
* @throws HttpNotFoundException
*/
private function importOpportunity($crmData): ?Opportunity
{
$profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);
$account = null;
if (empty($crmData['AccountId']) === false) {
/** @var ?Account $account */
$account = $this->config->accounts()
->where('crm_provider_id', (string) $crmData['AccountId'])
->first();
if ($account === null) {
$account = $this->syncAccount($crmData['AccountId']);
}
}
$userId = $profile?->getUserId() ?? $account?->getUserId();
if ($us...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
70643
|
2537
|
16
|
2026-05-22T10:26:41.885189+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779445601885_m2.jpg...
|
PhpStorm
|
Commit Changes
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Diff
Changelist:
Changelist:
Changes
Show Diff
Rol Diff
Changelist:
Changelist:
Changes
Show Diff
Rollback...
Refresh
Group By
Expand All
Collapse All
1 modified
JY-
Commit Message
Commit Message History
Git
Author:
Author:
Amend commit
Sign-off commit
Create extra commit with file movements
Commit Checks
Update copyright
Reformat code
Rearrange code
Optimize imports
Cleanup
Choose profile
Check malicious dependencies
Run external formatter
Configure
Perform SonarQube for IDE analysis
Find unused view files
Run Git hooks
Advanced Commit Checks
Analyze code
Choose profile
Check TODO
Configure
After Commit
Upload files to:
Upload files to:
<None>
Always use selected server or group of servers
Previous Difference
Next Difference
Jump to Source
Compare Previous File
Compare Next File
Go to Changed File…
Side-by-side viewer
Do not ignore
Highlight words
Collapse Unchanged Fragments
Synchronize Scrolling
Disable Editing
Settings
Help
4 differences
1aee7aad
<?php
namespace Jiminny\Services\Crm\Salesforce;
use Carbon\Carbon;
use Exception;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Events\Dispatcher;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Jiminny\Component\Country\CountriesMap;
use Jiminny\Contracts\Acl\PermissionEnum;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Contracts\Services\Crm\FetchRelatedActivityInterface;
use Jiminny\Contracts\Services\Crm\ImportsBusinessProcessesInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\SalesforceBatchSyncInterface;
use Jiminny\Contracts\Services\Crm\Provider\SalesforceInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\RemoteNoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SearchTaskInterface;
use Jiminny\Contracts\Services\Crm\SendSummaryToCrmInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SupportsObjectTypeParseInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmProfileRecordTypesInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Enums\CrmObject;
use Jiminny\Events\Activities\Crm\LeadConverted;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpBadRequestException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Exceptions\NoResultsException;
use Jiminny\Exceptions\ServiceUnavailableException;
use Jiminny\Jobs\Crm\NoteObject;
use Jiminny\Jobs\Crm\MatchActivitiesToNewOpportunity;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Calendar\CalendarEvent;
use Jiminny\Models\Contact;
use Jiminny\Models\Contracts\ActivityContract;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Models\Crm\ContactRole;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Crm\RecordType;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Playbook;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\TeamSettings;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\ContactRoleRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\Crm\RecordTypeFieldValuesRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Helpers\ArrayIterator;
use Jiminny\Services\Crm\MatchDomainByEmailInterface;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Services\Crm\ResolveCompanyNameByEmailTrait;
use Jiminny\Services\Crm\Salesforce\Fields\FieldHelper;
use Jiminny\Services\Crm\Salesforce\Fields\FieldTypeConverter;
use Jiminny\Services\Crm\Salesforce\Fields\ValueNormalizer;
use Jiminny\Services\Crm\Salesforce\ServiceTraits\FollowupActivityTrait;
use Jiminny\Services\Crm\Salesforce\ServiceTraits\LogActivityTrait;
use Jiminny\Services\Crm\Salesforce\ServiceTraits\RecordManipulationsTrait;
use Jiminny\Services\Crm\Salesforce\ServiceTraits\SyncFieldsTrait;
use Jiminny\Utils\CurrencyFormatter;
use Jiminny\Utils\StringUtil;
use Ramsey\Uuid\Uuid;
use Sentry\Laravel\Facade as Sentry;
class Service extends BaseService implements
SalesforceInterface,
SalesforceBatchSyncInterface,
SyncCrmEntitiesInterface,
SyncCrmProfileRecordTypesInterface,
ImportsBusinessProcessesInterface,
RemoteEntityManipulationInterface,
FetchRelatedActivityInterface,
SendSummaryToCrmInterface,
MatchDomainByEmailInterface,
SearchTaskInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
SupportsObjectTypeParseInterface,
RemoteNoteEntityManipulationInterface,
VerifyTaskExistsInterface
{
use ResolveCompanyNameByEmailTrait;
use SyncFieldsTrait;
use DeleteObjectsTrait;
use RecordManipulationsTrait;
use ServiceTraits\BatchSyncTrait;
use FollowupActivityTrait;
use LogActivityTrait;
/**
* Note Body Limit for the Old Note-Taking Tool
*
* @var int
*/
private const int CLASSIC_NOTE_MAX_LENGTH = 32000;
/**
* Note Content Limit for the New Notes
*
* @var int
*/
private const int ENHANCED_NOTE_MAX_LENGTH = 50000000;
private const string INSTALLED_PACKAGE_ID = '033Tw0000007bKbIAI';
private const int CACHE_TTL = 600;
private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day - 86400
/**
* @var Client
*/
protected $client;
protected PayloadBuilder $payloadBuilder;
protected QueryHandler $queryHandler;
private OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
public function __construct(
Client $client,
PayloadBuilder $payloadBuilder,
protected Dispatcher $eventDispatcher,
private readonly CountriesMap $countriesMap,
private readonly ProspectPhotoPathService $prospectPhotoPathService,
) {
parent::__construct();
$this->client = $client;
$this->payloadBuilder = $payloadBuilder;
$this->queryHandler = app(QueryHandler::class, [
'client' => $this->client,
'logger' => $this->logger,
]);
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
}
public function getDisplayName(): string
{
return 'Salesforce';
}
public function getJobDelay(): int
{
return 1;
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
return $user->getSocialAccount(SocialAccount::PROVIDER_SALESFORCE);
}
public function verifyTaskExists(Activity $activity): bool
{
$crmProviderId = $activity->getCrmProviderId();
$cacheKey = "crm_task_exists:{$this->config->getId()}:$crmProviderId";
return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($activity, $crmProviderId) {
$playbook = $this->getPlaybookFromActivity($activity);
if ($playbook === null) {
$this->logger->warning('[Salesforce] Cannot verify task - no playbook found', [
'activity' => $activity->getId(),
'crm_provider_id' => $crmProviderId,
]);
return false;
}
$objectType = $playbook->getActivityType() === Playbook::ACTIVITY_TYPE_EVENT ? 'Event' : 'Task';
try {
$record = $this->getRecord($objectType, $crmProviderId, ['Id', 'IsDeleted']);
return ! empty($record) && ($record['IsDeleted'] ?? false) === false;
} catch (HttpNotFoundException|HttpBadRequestException) {
$this->logger->info('[Salesforce] Activity record not found during verification', [
'activity' => $activity->getId(),
'object_type' => $objectType,
'crm_provider_id' => $crmProviderId,
'config_id' => $this->config->getId(),
]);
return false;
}
});
}
public function query(string $queryToRun, array $parameters = []): QueryIterator
{
// Due to poorly designed external calls, this method cannot be entirely removed
return $this->queryHandler->query($queryToRun, $parameters);
}
/*=========== Organization Information ===============*/
/**
* Get a list of all the API Versions for the instance.
*
* @throws CrmException
*
* @return mixed
*
*/
public function getApiVersions()
{
$url = $this->config->crm_base_url . '/services/data';
$response = $this->client->get($url);
return json_decode($response->getBody(), true);
}
/**
* Gets the valid recordTypes for a given Salesforce Object via the describe API.
*/
private function getRecordTypes(string $crmObject): array
{
$url = $this->client->getObjectsUrl() . $crmObject . '/describe';
$response = $this->client->get($url);
$jsonResponse = json_decode($response->getBody(), true);
$fields = [];
foreach ($jsonResponse['recordTypeInfos'] as $row) {
$fields[] = ['recordTypeId' => $row['recordTypeId'], 'default' => $row['defaultRecordTypeMapping']];
}
return $fields;
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return ValueNormalizer::normalize($fieldType, $fieldValue, $internal);
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
$defaultFields = ($activityType === Playbook::ACTIVITY_TYPE_TASK)
? FieldDefinitions::defaultTaskFields()
: FieldDefinitions::defaultEventFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
// Setup the activity field as the default Type.
/** @var Field $activityField */
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'Type',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK, Playbook::ACTIVITY_TYPE_EVENT];
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldFilter = ($activityType === Playbook::ACTIVITY_TYPE_TASK)
? FieldDefinitions::taskFollowupFieldsFilter()
: FieldDefinitions::eventFollowupFieldsFilter();
foreach ($fieldFilter as $eachFilter) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $eachFilter);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
/**
* This one is now called only when ImportActivityTypes is triggered or SyncFieldMetadata executed manually
* Regular sync now uses SharedSyncFieldsTrait -> syncSingleObjectType
* Needs to be replaced later on
*/
public function syncField(Field $field): void
{
try {
if (FieldHelper::isCustomField($field)) {
$query = '
SELECT
Id, Metadata, TableEnumOrId
FROM
CustomField
WHERE
DeveloperName = :fieldName
AND
TableEnumOrId = :fieldType
AND
NamespacePrefix = :namespacePrefix';
// We need to constrain the field lookup to the object, in case it's used in multiple places.
$objectType = \in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true)
? 'activity'
: $field->object_type;
$sfFields = $this->queryHandler->metadata($query, [
'fieldName' => substr($field->crm_provider_id, 0, -\strlen('__c')),
'fieldType' => ucfirst($objectType),
// This is used to ensure we only consider the field within the org, not installed packages.
'namespacePrefix' => 'null',
]);
// There is always 1 result at this point.
$sfField = $sfFields->current();
// Sync field metadata.
$metadata = $sfField['Metadata'];
$field->description = mb_strimwidth($metadata['description'] ?? '', 0, 191);
$field->label = mb_strimwidth($metadata['label'] ?? '', 0, Field::LABEL_MAX_LENGTH);
$field->type = $this->convertFieldType($metadata['type'], $field->getEntityName());
$field->is_mandatory = ($metadata['required'] === true);
$field->length = $metadata['length'];
$field->default_value = mb_strimwidth(trim($metadata['defaultValue'] ?? '', '"'), 0, 191);
$field->save();
} else {
$query = '
SELECT
Id, DataType, DeveloperName, Label, Length, Description
FROM
FieldDefinition
WHERE
DurableId = :entityName';
$entityName = $field->getEntityName();
$sfFields = $this->queryHandler->metadata($query, [
'entityName' => $entityName,
]);
// There is always 1 result at this point.
$sfField = $sfFields->current();
$convertedType = $this->convertFieldType($sfField['DataType'], $entityName);
$label = mb_strimwidth($sfField['Label'], 0, Field::LABEL_MAX_LENGTH);
if ($field->isBusinessType()) {
$label = 'Opportunity Type';
}
$field->description = mb_strimwidth($sfField['Description'], 0, Field::DESCRIPTION_MAX_LENGTH);
$field->label = $label;
$field->type = $convertedType;
$field->length = $sfField['Length'];
$field->save();
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
}
private function convertFieldType(string $from, ?string $entityName = null): string
{
$converter = new FieldTypeConverter();
return $converter->convert($from, $entityName);
}
/**
* @inheritdoc
*/
public function importPicklistValues(Field $field): array
{
$values = [];
$fieldValues = [];
try {
if (FieldHelper::isCustomField($field)) {
$query = '
SELECT
Id, Metadata, TableEnumOrId
FROM
CustomField
WHERE
DeveloperName = :fieldName
AND
TableEnumOrId = :fieldType
AND
NamespacePrefix = :namespacePrefix';
// We need to constrain the field lookup to the object, in case it's used in multiple places.
$objectType = \in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true) ?
'activity' : $field->object_type;
$sfFields = $this->queryHandler->metadata($query, [
'fieldName' => substr($field->crm_provider_id, 0, -\strlen('__c')),
'fieldType' => ucfirst($objectType),
// This is used to ensure we only consider the field within the org, not installed packages.
'namespacePrefix' => 'null',
]);
// There is always 1 result at this point.
$sfField = $sfFields->current();
$valueSet = $sfField['Metadata']['valueSet'];
if ($valueSet['valueSetName'] === null) {
// Local picklist values can be obtained easily.
$picklistValues = $valueSet['valueSetDefinition']['value'];
} else {
// But for some fields, we just get the Global Value Picklist pointer so need to do more work.
$picklistValues = $this->importGlobalValuePicklistValues($valueSet['valueSetName']);
}
// Import all active values.
foreach ($picklistValues as $i => $sfFieldValue) {
// Setup default value.
if ($sfFieldValue['default']) {
$field->update(['default_value' => $sfFieldValue['valueName']]);
}
// This comes through as null if active (lol).
if ($sfFieldValue['isActive'] !== false) {
$values[] = [
'value' => $sfFieldValue['valueName'],
'label' => $sfFieldValue['valueName'],
'sequence' => $i,
'is_default' => $sfFieldValue['default'],
];
}
}
} else {
$objectFields = $this->getObjectFields($field->object_type);
$fieldId = $field->crm_provider_id;
// Only work with our field of interest.
$objectField = array_filter($objectFields, function ($item) use ($fieldId) {
return $item['name'] === $fieldId;
});
$objectField = array_shift($objectField);
if (empty($objectField['picklistValues']) === false) {
foreach ($objectField['picklistValues'] as $i => $sfFieldValue) {
// Skip inactive values.
if ($sfFieldValue['active'] === false) {
continue;
}
// Setup default value.
if ($sfFieldValue['defaultValue']) {
$field->update(['default_value' => $sfFieldValue['value']]);
}
$values[] = [
'value' => $sfFieldValue['value'],
'label' => $sfFieldValue['label'],
'sequence' => $i,
'is_default' => $sfFieldValue['defaultValue'],
];
}
}
}
$fieldsToPurge = $field->values()->get()->pluck('value')->toArray();
foreach ($values as $value) {
$value['value'] = substr($value['value'] ?? '', 0, 255);
$fieldValues[] = $field->values()->updateOrCreate([
'value' => $value['value'],
], $value);
// Remove this value from the ones we are going to purge.
if (($key = array_search($value['value'], $fieldsToPurge, true)) !== false) {
unset($fieldsToPurge[$key]);
}
}
// Delete the old values that are no longer used.
// Get IDs of the values to be deleted
$valuesToDelete = $field->values()->whereIn('value', $fieldsToPurge);
$valuesToDeleteIds = $valuesToDelete->pluck('id');
if (! $valuesToDeleteIds->isEmpty()) {
$recordTypeFieldValuesRepository = app(RecordTypeFieldValuesRepository::class);
$recordTypeFieldValuesRepository->deleteForCrmFieldValueIds($valuesToDeleteIds->toArray());
// Now safely delete from crm_field_values
$valuesToDelete->delete();
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
return $fieldValues;
}
/**
* Gets values from Global Value Picklists.
*/
private function importGlobalValuePicklistValues(string $picklistName): array
{
$query = '
SELECT
Metadata
FROM
GlobalValueSet
WHERE
DeveloperName = :picklistName
LIMIT 1';
try {
$sfValues = $this->queryHandler->metadata($query, [
'picklistName' => $picklistName,
]);
// There is always 1 result at this point.
$sfValue = $sfValues->current();
return $sfValue['Metadata']['customValue'];
} catch (NoResultsException $noResultsException) {
// Nothing returned.
return [];
}
}
/**
* @inheritdoc
*/
public function syncProfileRecordTypes(): void
{
$objectTypes = [
'lead',
'account',
'contact',
'opportunity',
'task',
'event',
];
foreach ($objectTypes as $objectType) {
try {
$crmRecordTypes = $this->getRecordTypes(ucfirst($objectType));
foreach ($crmRecordTypes as $crmRecordType) {
// If the record type is default and not the Master type, set this.
if ($crmRecordType['default'] && $crmRecordType['recordTypeId'] !== '012000000000000AAA') {
$recordType = $this->config->recordTypes()
->where('crm_provider_id', $crmRecordType['recordTypeId'])
->first();
if ($recordType) {
$this->profile->{$objectType . '_record_type_id'} = $recordType->id;
}
}
}
} catch (HttpNotFoundException $exception) {
Log::error('No access to ' . $objectType . ' object, skipping...');
// XXX: should we log this fact somewhere?
continue;
}
}
if ($this->profile->isDirty()) {
$this->profile->save();
}
}
/**
* Gets business processes.
*/
public function importBusinessProcesses(): void
{
$query = '
SELECT
Id, IsActive, Name, TableEnumOrId
FROM
BusinessProcess
WHERE
TableEnumOrId IN (\'Lead\',\'Opportunity\')';
try {
$sfProcesses = $this->queryHandler->query($query);
// Upsert all processes for the team.
foreach ($sfProcesses as $sfProcess) {
/** @var BusinessProcess $businessProcess */
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => $sfProcess['Id'],
], [
'team_id' => $this->team->id,
'name' => $sfProcess['Name'],
'type' => $sfProcess['TableEnumOrId'] === 'Lead' ? 'lead' : 'opportunity',
'is_selectable' => $sfProcess['IsActive'],
]);
$this->importBusinessProcessStages($businessProcess);
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
}
/**
* Gets business process stages.
*/
private function importBusinessProcessStages(BusinessProcess $businessProcess): void
{
$query = '
SELECT
Metadata
FROM
BusinessProcess
WHERE
Id = :processId';
try {
$stages = [];
$sfProcessStages = $this->queryHandler->metadata($query, [
'processId' => $businessProcess->crm_provider_id,
]);
// There is always 1 result at this point.
$sfProcessStage = $sfProcessStages->current();
// Upsert all processes for the team.
foreach ($sfProcessStage['Metadata']['values'] as $sfProcessStage) {
$sanitizedName = urldecode($sfProcessStage['valueName']); // Must decode: "%2C" becomes "," etc.
$stage = $businessProcess->crm->stages()
// This MUST match on label because this API doesn't use API Name.
->where('label', $sanitizedName)
->where('type', $businessProcess->type)
->where('is_selectable', 1)
->first();
if ($stage) {
$stages[] = $stage->id;
}
}
$businessProcess->stages()->sync($stages);
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
}
/**
* Gets record types.
*/
public function importRecordTypes(): void
{
$query = '
SELECT
Id, IsActive, Name, BusinessProcessId, SobjectType
FROM
RecordType';
try {
$sfRecordTypes = $this->queryHandler->query($query);
// Upsert all record types for the process.
foreach ($sfRecordTypes as $sfRecordType) {
$businessProcess = null;
if ($sfRecordType['BusinessProcessId']) {
$businessProcess = $this->config->businessProcesses()
->where('crm_provider_id', $sfRecordType['BusinessProcessId'])
->first();
}
/** @var RecordType $recordType */
$recordType = $this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => $sfRecordType['Id'],
], [
'team_id' => $this->team->id,
'type' => mb_strtolower($sfRecordType['SobjectType']),
'name' => $sfRecordType['Name'],
'is_selectable' => $sfRecordType['IsActive'],
'business_process_id' => $businessProcess->id ?? null,
]);
$this->importRecordTypeFieldValues($recordType);
}
} catch (NoResultsException $noResultsException) {
// Do nothing.
}
}
/**
* Import record type - field value mappings. This only works for standard fields.
*/
private function importRecordTypeFieldValues(RecordType $recordType): void
{
try {
$query = '
SELECT
Metadata
FROM
RecordType
WHERE
Id = :recordTypeId';
$sfFields = $this->queryHandler->metadata($query, [
'recordTypeId' => $recordType->crm_provider_id,
]);
// There is always 1 result at this point.
$sfField = $sfFields->current();
// Sync field metadata.
$picklists = $sfField['Metadata']['picklistValues'];
foreach ($picklists as $picklist) {
$field = $this->config->fields()->where([
'type' => Field::TYPE_PICKLIST,
'object_type' => $recordType->type,
'crm_provider_id' => $picklist['picklist'],
])->first();
if ($field) {
$fieldValues = [];
foreach ($picklist['values'] as $value) {
// Must decode: "%2C" becomes "," etc.
$fieldValue = $field->values()
->where('value', urldecode($value['valueName']))
->first();
if ($fieldValue) {
$fieldValues[] = $fieldValue->id;
}
}
$recordType->fieldValues()->sync($fieldValues);
}
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
}
/**
* @inheritdoc
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$params = [];
$missingStage = null;
if ($types === null) {
$types = [Stage::TYPE_LEAD, Stage::TYPE_OPPORTUNITY];
}
foreach ($types as $type) {
if ($type === Stage::TYPE_LEAD) {
$query = '
SELECT
Id, ApiName, MasterLabel, SortOrder
FROM
LeadStatus';
} else {
$query = '
SELECT
Id, ApiName, MasterLabel, IsActive, SortOrder, DefaultProbability
FROM
OpportunityStage';
}
if ($missingStageName) {
$escapedStageName = ValueNormalizer::replaceQueryWithStringLiterals($missingStageName);
$query .= ' WHERE ApiName = :stageName';
$params = [
'stageName' => $escapedStageName,
];
}
try {
$sfStages = $this->queryHandler->query($query, $params);
} catch (NoResultsException $exception) {
$sfStages = [];
}
$missingStage = null;
// Upsert all stages for the team.
foreach ($sfStages as $sfStage) {
$selectable = true;
if (array_key_exists('IsActive', $sfStage)) {
$selectable = $sfStage['IsActive'];
}
$this->restoreAnyTrashedEntity($this->config->stages(), $sfStage['Id']);
$stage = $this->config->stages()->updateOrCreate([
'crm_provider_id' => $sfStage['Id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($sfStage['ApiName'], 0, 50),
'label' => mb_strimwidth($sfStage['MasterLabel'], 0, 191),
'type' => $type,
'sequence' => $sfStage['SortOrder'] ?? 0,
'is_selectable' => $selectable,
'probability' => $sfStage['DefaultProbability'] ?? null,
]);
if ($missingStageName && $missingStageName === $sfStage['ApiName']) {
$missingStage = $stage;
}
}
if ($missingStageName && $missingStage === null) {
// If they requested a stage that still doesn't exist, it must be inactive so lazy create it.
$missingStage = $this->config->stages()->create([
'crm_provider_id' => Uuid::uuid4(),
'team_id' => $this->team->id,
'name' => mb_strimwidth($missingStageName, 0, 50),
'label' => mb_strimwidth($missingStageName, 0, 191),
'type' => $type,
'sequence' => 0,
'is_selectable' => 0,
]);
}
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int
{
$syncCount = 0;
$fields = $this->getAllFieldsAsArray('lead');
if (\in_array('Id', $fields, true) === false) {
return $syncCount;
}
$query = '
SELECT ' . rtrim(implode(',', $fields), ',') . '
FROM Lead
WHERE LastModifiedDate > :since
ORDER BY LastModifiedDate ASC';
try {
$sfLeads = $this->queryHandler->query($query, [
'since' => $since->format('Y-m-d\TH:i:s\Z'),
]);
foreach ($sfLeads as $sfLead) {
// Only sync if previously imported.
if ($this->hasLead($sfLead['Id'])) {
$this->importLead($sfLead);
$syncCount++;
}
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
$this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::LEAD);
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncLead(string $crmId): ?Lead
{
$fields = $this->getAllFieldsAsArray('lead');
$sfLead = $this->getRecord('Lead', $crmId, $fields);
return $this->importLead($sfLead);
}
private function importLead($crmData): ?Lead
{
/** @var ?Stage $stage */
$stage = null;
if (isset($crmData['Status'])) {
// Get the current stage.
$stage = $this->config
->stages()
->where('name', $crmData['Status'])
->where('type', Stage::TYPE_LEAD)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_LEAD], $crmData['Status']);
}
}
// If we have no way of importing this, just return null :(
if ($stage === null) {
return null;
}
$countryCode = $crmData['CountryCode'] ?? null;
// Salesforce allows custom "countries" to be created. Disregard these.
if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {
$countryCode = null;
}
// If we have no country code, try to parse it from the country name.
if ($countryCode === null && empty($crmData['Country']) !== false) {
$countryCode = $this->convertCountryNameToCode($crmData['Country']);
}
// Trim to our width and attempt to parse it.
$number = mb_strimwidth($crmData['Phone'] ?? '', 0, 25);
$parsedNumber = parsePhoneNumber($countryCode, $number);
$mobilePhone = null;
if (empty($crmData['MobilePhone']) === false) {
// Trim to our width and attempt to parse it.
$number = mb_strimwidth($crmData['MobilePhone'], 0, 25);
$mobilePhone = phone_e164($countryCode, $number);
}
$convertedDate = null;
$convertedAccount = null;
$convertedOpportunity = null;
$convertedContact = null;
if ($crmData['IsConverted'] == 'true') {
$convertedDate = $crmData['ConvertedDate'];
if (empty($crmData['ConvertedAccountId']) === false) {
$convertedAccount = $this->config
->accounts()
->where('crm_provider_id', $crmData['ConvertedAccountId'])
->first();
if ($convertedAccount === null) {
try {
$convertedAccount = $this->syncAccount($crmData['ConvertedAccountId']);
} catch (HttpNotFoundException $exception) {
// Probably the user has no permissions to access the converted data.
}
}
}
if (empty($crmData['ConvertedOpportunityId']) === false) {
$convertedOpportunity = $this->config
->opportunities()
->where('crm_provider_id', $crmData['ConvertedOpportunityId'])
->first();
if ($convertedOpportunity === null) {
try {
$convertedOpportunity = $this->syncOpportunity($crmData['ConvertedOpportunityId']);
} catch (HttpNotFoundException $exception) {
// Probably the user has no permissions to access the converted data.
}
}
}
if (empty($crmData['ConvertedContactId']) === false) {
$convertedContact = $this->team
->crm
->contacts()
->where('crm_provider_id', $crmData['ConvertedContactId'])
->first();
if ($convertedContact === null) {
try {
$convertedContact = $this->syncContact($crmData['ConvertedContactId']);
} catch (HttpNotFoundException $exception) {
// Probably the user has no permissions to access the converted data.
}
}
}
}
if (empty($crmData['Company'])) {
$company = 'Unknown';
} else {
$company = mb_strimwidth($crmData['Company'], 0, 191);
}
$domain = null;
if (empty($crmData['Website']) === false) {
$domain = mb_strimwidth($crmData['Website'], 0, 191);
$domain = StringUtil::resolveDomain($domain);
}
$createdDate = null;
if (empty($crmData['CreatedDate']) === false) {
$createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');
}
$profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);
$data = [
'team_id' => $this->team->id,
'user_id' => $profile?->user_id,
'owner_id' => $crmData['OwnerId'] ?? '',
'company' => $company,
'domain' => $domain,
'name' => $crmData['Name'] ? mb_strimwidth($crmData['Name'], 0, 191) : '',
'title' => $crmData['Title'] ? mb_strimwidth($crmData['Title'], 0, 128) : null,
'email' => $crmData['Email'] ? mb_strimwidth($crmData['Email'], 0, 80) : null,
'phone' => $parsedNumber['phone'],
'ext' => $parsedNumber['ext'] ?? null,
'mobile_phone' => $mobilePhone,
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: $crmData['Id'],
modelType: Lead::class,
fileName: $crmData['Id'],
avatarText: $crmData['Name']
),
'stage_id' => $stage->id,
'record_type_id' => null,
'converted_at' => $convertedDate,
'converted_account_id' => $convertedAccount->id ?? null,
'converted_opportunity_id' => $convertedOpportunity->id ?? null,
'converted_contact_id' => $convertedContact->id ?? null,
'country_code' => $countryCode,
'remotely_created_at' => $createdDate,
];
$this->restoreAnyTrashedEntity($this->config->leads(), $crmData['Id']);
/** @var Lead $lead */
$lead = $this->config->leads()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);
if ($lead->wasChanged('converted_at') && $lead->getConvertedAt() !== null) {
$this->eventDispatcher->dispatch(new LeadConverted($lead));
}
$this->handleObjectDeletion($lead, $crmData);
return $lead;
}
/**
* @inheritdoc
*/
public function syncAccounts(Carbon $since, ?Carbon $to = null): int
{
$syncCount = 0;
$fields = $this->getAllFieldsAsArray('account');
if (\in_array('Id', $fields, true) === false) {
return $syncCount;
}
$query = '
SELECT ' . rtrim(implode(',', $fields), ',') . '
FROM Account
WHERE LastModifiedDate > :since
ORDER BY LastModifiedDate ASC';
try {
$sfAccounts = $this->queryHandler->query($query, [
'since' => $since->format('Y-m-d\TH:i:s\Z'),
]);
foreach ($sfAccounts as $sfAccount) {
// Only sync if previously imported.
if ($this->hasAccount($sfAccount['Id'])) {
$this->importAccount($sfAccount);
$syncCount++;
}
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
$this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::ACCOUNT);
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncAccount(string $crmId): ?Account
{
$fields = $this->getAllFieldsAsArray('account');
if (! in_array('Id', $fields, true)) {
$this->logger->info('[Salesforce] Sync account cancelled. Fields are not available.', [
'crmId' => $crmId,
'userId' => $this->profile->getUserId(),
]);
return null;
}
$sfAccount = $this->getRecord('Account', $crmId, $fields);
return $this->importAccount($sfAccount);
}
private function importAccount($crmData): Account
{
$countryCode = $crmData['BillingCountryCode'] ?? $crmData['ShippingCountryCode'] ?? null;
// Salesforce allows custom "countries" to be created. Disregard these.
if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {
$countryCode = null;
}
// If we have no country code, try to parse it from the country names.
if ($countryCode === null && empty($crmData['BillingCountry']) === false) {
$countryCode = $this->convertCountryNameToCode($crmData['BillingCountry']);
}
if ($countryCode === null && empty($crmData['ShippingCountry']) === false) {
$countryCode = $this->convertCountryNameToCode($crmData['ShippingCountry']);
}
if (empty($crmData['Phone']) === false) {
// Trim to our width and attempt to parse it.
$number = mb_strimwidth($crmData['Phone'], 0, 25);
$parsedNumber = parsePhoneNumber($countryCode, $number);
} else {
$parsedNumber = [];
}
$industry = null;
if (empty($crmData['Industry']) === false) {
$industry = mb_strimwidth($crmData['Industry'], 0, 40);
}
$domain = null;
if (empty($crmData['Website']) === false) {
$domain = mb_strimwidth($crmData['Website'], 0, 191);
$domain = StringUtil::resolveDomain($domain);
}
$createdDate = null;
if (empty($crmData['CreatedDate']) === false) {
$createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');
}
$profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);
$data = [
'team_id' => $this->team->id,
'user_id' => $profile?->user_id,
'owner_id' => $crmData['OwnerId'],
'name' => mb_strimwidth($crmData['Name'], 0, 191),
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: $crmData['Id'],
modelType: Account::class,
fileName: $crmData['Id'],
avatarText: $crmData['Name']
),
'industry' => $industry,
'domain' => $domain,
'phone' => $parsedNumber['phone'] ?? null,
'ext' => $parsedNumber['ext'] ?? null,
'country_code' => $countryCode,
'remotely_created_at' => $createdDate,
];
$this->restoreAnyTrashedEntity($this->config->accounts(), $crmData['Id']);
/** @var Account $account */
$account = $this->config->accounts()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);
$this->handleObjectDeletion($account, $crmData);
return $account;
}
/**
* @inheritdoc
*/
public function syncOpportunities(array $parameters, ?string $strategy = null): int
{
$strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);
$syncCount = 0;
$logParams = $parameters;
$parameters['profile'] = $this->profile;
$logParams['user'] = $this->profile->getUserId();
if (count($strategies) > 1) {
$this->logger->warning('[' . $this->getDisplayName() . '] Multiple sync strategies used', [
'teamId' => $this->team->getUuid(),
'params' => $logParams,
'strategies_count' => count($strategies),
]);
}
foreach ($strategies as $syncStrategy) {
$name = $syncStrategy->getStrategyName();
try {
$sfOpportunities = $syncStrategy->fetchOpportunities($parameters);
$totalRecords = $sfOpportunities->count();
foreach ($sfOpportunities as $sfOpportunity) {
$this->importOpportunity($sfOpportunity);
$syncCount++;
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
$this->logger->warning('[' . $this->getDisplayName() . '] No opportunities found', [
'teamId' => $this->team->getUuid(),
'name' => $name,
'params' => $logParams,
'reason' => $noResultsException->getMessage(),
]);
} catch (CrmException $crmException) {
// Nothing to sync.
$this->logger->warning('[' . $this->getDisplayName() . '] Opportunity sync failed', [
'teamId' => $this->team->getUuid(),
'name' => $name,
'params' => $logParams,
'reason' => $crmException->getMessage(),
]);
}
}
$this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::OPPORTUNITY, ['params' => $logParams]);
// debug to see how if count of opportunities reaches 1000
if ($syncCount >= 1000) {
$this->logger->info(
'[' . $this->getDisplayName() . '] Sync Opportunities - count warning',
[
'team_id' => $this->team->getId(),
'params' => $logParams,
'count' => $syncCount,
'strategies_count' => count($strategies),
'total_records' => $totalRecords ?? null,
]
);
}
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncOpportunity(string $crmId): ?Opportunity
{
$strategy = $this->opportunitySyncStrategyResolver->resolve(
$this->config,
OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY
);
$parameters = [
'profile' => $this->profile,
'crm_id' => $crmId,
];
try {
$sfOpportunity = $strategy->fetchOpportunities($parameters);
} catch (HttpNotFoundException $e) {
$this->logger->info('[' . $this->getDisplayName() . '] Opportunity not found', [
'teamId' => $this->team->id_string,
'crmId' => $crmId,
]);
return null;
} catch (CrmException $crmException) {
$this->logger->info('[' . $this->getDisplayName() . '] Opportunity sync failed', [
'teamId' => $this->team->id_string,
'crmId' => $crmId,
'exception' => $crmException->getMessage(),
]);
return null;
}
if ($sfOpportunity instanceof ArrayIterator) {
return $this->importOpportunity($sfOpportunity->getItems());
}
return $this->importOpportunity($sfOpportunity);
}
/**
* @throws HttpNotFoundException
*/
private function importOpportunity($crmData): ?Opportunity
{
$profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);
$account = null;
if (empty($crmData['AccountId']) === false) {
/** @var ?Account $account */
$account = $this->config->accounts()
->where('crm_provider_id', (string) $crmData['AccountId'])
->first();
if ($account === null) {
$account = $this->syncAccount($crmData['AccountId']);
}
}
$userId = $profile?->getUserId() ?? $account?->getUserId();
if ($user...
|
[{"role":"AXStaticText","text& [{"role":"AXStaticText","text":"Diff","depth":1,"bounds":{"left":0.25531915,"top":0.39185953,"width":0.013962766,"height":0.013567438},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Changelist:","depth":1,"bounds":{"left":0.38231382,"top":0.15802075,"width":0.022938829,"height":0.027134877},"on_screen":true,"role_description":"text"},{"role":"AXPopUpButton","text":"Changelist:","depth":1,"bounds":{"left":0.40658244,"top":0.15802075,"width":0.042220745,"height":0.027134877},"on_screen":true,"role_description":"pop up button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Changes","depth":6,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Show Diff","depth":2,"bounds":{"left":0.25664893,"top":0.16201118,"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":"Rollback...","depth":2,"bounds":{"left":0.26529256,"top":0.16201118,"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":"Refresh","depth":2,"bounds":{"left":0.27393618,"top":0.16201118,"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":"Group By","depth":2,"bounds":{"left":0.28490692,"top":0.16201118,"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":"Expand All","depth":2,"bounds":{"left":0.3617021,"top":0.16201118,"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":"Collapse All","depth":2,"bounds":{"left":0.37034574,"top":0.16201118,"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":"AXStaticText","text":"1 modified","depth":1,"bounds":{"left":0.25531915,"top":0.254589,"width":0.1931516,"height":0.016759777},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"JY-","depth":2,"bounds":{"left":0.2556516,"top":0.3008779,"width":0.19281915,"height":0.087789305},"on_screen":true,"value":"JY-","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Commit Message","depth":1,"bounds":{"left":0.25531915,"top":0.28252193,"width":0.03557181,"height":0.013567438},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Commit Message History","depth":2,"bounds":{"left":0.4401596,"top":0.27853152,"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":"AXStaticText","text":"Git","depth":2,"bounds":{"left":0.45212767,"top":0.16280925,"width":0.0056515955,"height":0.013567438},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Author:","depth":2,"bounds":{"left":0.46010637,"top":0.18834797,"width":0.014960106,"height":0.013567438},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"Author:","depth":3,"bounds":{"left":0.4787234,"top":0.18834797,"width":0.09840426,"height":0.013567438},"on_screen":true,"role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Amend commit","depth":2,"bounds":{"left":0.4587766,"top":0.21069433,"width":0.039893616,"height":0.019952115},"on_screen":true,"help_text":"Merge this commit with the previous one","role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Sign-off commit","depth":2,"bounds":{"left":0.4587766,"top":0.23383878,"width":0.042220745,"height":0.019952115},"on_screen":true,"help_text":"<html>Adds the following line at the end of the commit message:<br/>Signed-off by: Lukas Kovalik <kovaliklukas@gmail.com></html>","role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Create extra commit with file movements","depth":2,"on_screen":false,"role_description":"checkbox","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Commit Checks","depth":2,"bounds":{"left":0.45212767,"top":0.27613726,"width":0.032247342,"height":0.013567438},"on_screen":true,"role_description":"text"},{"role":"AXCheckBox","text":"Update copyright","depth":2,"bounds":{"left":0.4587766,"top":0.29608938,"width":0.04488032,"height":0.019952115},"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Reformat code","depth":2,"bounds":{"left":0.4587766,"top":0.31923383,"width":0.03956117,"height":0.019952115},"on_screen":true,"help_text":"","role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Rearrange code","depth":2,"bounds":{"left":0.4587766,"top":0.3423783,"width":0.041888297,"height":0.019952115},"on_screen":true,"help_text":"","role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Optimize imports","depth":2,"bounds":{"left":0.4587766,"top":0.36552274,"width":0.044215426,"height":0.019952115},"on_screen":true,"help_text":"","role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Cleanup","depth":2,"bounds":{"left":0.4587766,"top":0.3886672,"width":0.026263298,"height":0.019952115},"on_screen":true,"help_text":"","role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"Choose profile","depth":2,"bounds":{"left":0.48902926,"top":0.39185953,"width":0.029920213,"height":0.013567438},"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Check malicious dependencies","depth":2,"bounds":{"left":0.4587766,"top":0.41181165,"width":0.07247341,"height":0.019952115},"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Run external formatter","depth":2,"bounds":{"left":0.4587766,"top":0.4349561,"width":0.05518617,"height":0.019952115},"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"Configure","depth":2,"bounds":{"left":0.51795214,"top":0.43814844,"width":0.019946808,"height":0.013567438},"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Perform SonarQube for IDE analysis","depth":2,"bounds":{"left":0.4587766,"top":0.45810056,"width":0.1200133,"height":0.019952115},"on_screen":true,"help_text":"","role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Find unused view files","depth":2,"bounds":{"left":0.4587766,"top":0.481245,"width":0.054521278,"height":0.019952115},"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Run Git hooks","depth":2,"on_screen":false,"help_text":"If unchecked, Git hooks will be skipped with the '--no-verify' option for the upcoming commit","role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Advanced Commit Checks","depth":2,"bounds":{"left":0.45212767,"top":0.5235435,"width":0.05418883,"height":0.013567438},"on_screen":true,"role_description":"text"},{"role":"AXCheckBox","text":"Analyze code","depth":2,"bounds":{"left":0.4587766,"top":0.5434956,"width":0.036901597,"height":0.019952115},"on_screen":true,"help_text":"","role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"Choose profile","depth":2,"bounds":{"left":0.49966756,"top":0.54668796,"width":0.029920213,"height":0.013567438},"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Check TODO","depth":2,"bounds":{"left":0.4587766,"top":0.5666401,"width":0.035904255,"height":0.019952115},"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"Configure","depth":2,"bounds":{"left":0.49867022,"top":0.5698324,"width":0.019946808,"height":0.013567438},"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"After Commit","depth":2,"bounds":{"left":0.45212767,"top":0.6089386,"width":0.027260639,"height":0.013567438},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Upload files to:","depth":2,"bounds":{"left":0.46010637,"top":0.6360734,"width":0.030585106,"height":0.013567438},"on_screen":true,"role_description":"text"},{"role":"AXPopUpButton","text":"Upload files to:","depth":2,"bounds":{"left":0.49168882,"top":0.62968874,"width":0.07679521,"height":0.027134877},"on_screen":true,"role_description":"pop up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"<None>","depth":7,"on_screen":false,"role_description":"text"},{"role":"AXCheckBox","text":"Always use selected server or group of servers","depth":2,"bounds":{"left":0.46675533,"top":0.66081405,"width":0.10538564,"height":0.019952115},"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Previous Difference","depth":2,"bounds":{"left":0.25664893,"top":0.41340783,"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 Difference","depth":2,"bounds":{"left":0.26529256,"top":0.41340783,"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":"Jump to Source","depth":2,"bounds":{"left":0.27393618,"top":0.41340783,"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":"Compare Previous File","depth":2,"bounds":{"left":0.28490692,"top":0.41340783,"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":"Compare Next File","depth":2,"bounds":{"left":0.29355052,"top":0.41340783,"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":"Go to Changed File…","depth":2,"bounds":{"left":0.30219415,"top":0.41340783,"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":"Side-by-side viewer","depth":2,"bounds":{"left":0.3118351,"top":0.41340783,"width":0.04720745,"height":0.01915403},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Do not ignore","depth":2,"bounds":{"left":0.36336437,"top":0.41340783,"width":0.03557181,"height":0.01915403},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Highlight words","depth":2,"bounds":{"left":0.40093085,"top":0.41340783,"width":0.03956117,"height":0.01915403},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse Unchanged Fragments","depth":2,"bounds":{"left":0.44148937,"top":0.41340783,"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":"Synchronize Scrolling","depth":2,"bounds":{"left":0.45013297,"top":0.41340783,"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":"Disable Editing","depth":2,"bounds":{"left":0.4587766,"top":0.41340783,"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":"Settings","depth":2,"bounds":{"left":0.46742022,"top":0.41340783,"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":"Help","depth":2,"bounds":{"left":0.47839096,"top":0.41340783,"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":"AXStaticText","text":"4 differences","depth":1,"bounds":{"left":0.5511968,"top":0.41181165,"width":0.027260639,"height":0.022346368},"on_screen":true,"role_description":"text"},{"role":"AXTextField","text":"1aee7aad","depth":1,"bounds":{"left":0.26196808,"top":0.43415803,"width":0.022606382,"height":0.013567438},"on_screen":true,"value":"1aee7aad","help_text":"text/plain","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Salesforce;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse Illuminate\\Database\\Eloquent\\Relations\\HasMany;\nuse Illuminate\\Events\\Dispatcher;\nuse Illuminate\\Support\\Facades\\Cache;\nuse Illuminate\\Support\\Facades\\Log;\nuse Illuminate\\Support\\Str;\nuse Jiminny\\Component\\Country\\CountriesMap;\nuse Jiminny\\Contracts\\Acl\\PermissionEnum;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\FetchRelatedActivityInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\ImportsBusinessProcessesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\SalesforceBatchSyncInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\SalesforceInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteNoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SearchTaskInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SendSummaryToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SupportsObjectTypeParseInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmProfileRecordTypesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Enums\\CrmObject;\nuse Jiminny\\Events\\Activities\\Crm\\LeadConverted;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpBadRequestException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Exceptions\\NoResultsException;\nuse Jiminny\\Exceptions\\ServiceUnavailableException;\nuse Jiminny\\Jobs\\Crm\\NoteObject;\nuse Jiminny\\Jobs\\Crm\\MatchActivitiesToNewOpportunity;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Calendar\\CalendarEvent;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Contracts\\ActivityContract;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Configuration;\nuse Jiminny\\Models\\Crm\\ContactRole;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Crm\\RecordType;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\TeamSettings;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\ContactRoleRepository;\nuse Jiminny\\Repositories\\Crm\\FieldRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Repositories\\Crm\\RecordTypeFieldValuesRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\Helpers\\ArrayIterator;\nuse Jiminny\\Services\\Crm\\MatchDomainByEmailInterface;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Services\\Crm\\ResolveCompanyNameByEmailTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\Fields\\FieldHelper;\nuse Jiminny\\Services\\Crm\\Salesforce\\Fields\\FieldTypeConverter;\nuse Jiminny\\Services\\Crm\\Salesforce\\Fields\\ValueNormalizer;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\FollowupActivityTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\LogActivityTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\RecordManipulationsTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\SyncFieldsTrait;\nuse Jiminny\\Utils\\CurrencyFormatter;\nuse Jiminny\\Utils\\StringUtil;\nuse Ramsey\\Uuid\\Uuid;\nuse Sentry\\Laravel\\Facade as Sentry;\n\nclass Service extends BaseService implements\n SalesforceInterface,\n SalesforceBatchSyncInterface,\n SyncCrmEntitiesInterface,\n SyncCrmProfileRecordTypesInterface,\n ImportsBusinessProcessesInterface,\n RemoteEntityManipulationInterface,\n FetchRelatedActivityInterface,\n SendSummaryToCrmInterface,\n MatchDomainByEmailInterface,\n SearchTaskInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n SupportsObjectTypeParseInterface,\n RemoteNoteEntityManipulationInterface,\n VerifyTaskExistsInterface\n{\n use ResolveCompanyNameByEmailTrait;\n use SyncFieldsTrait;\n use DeleteObjectsTrait;\n use RecordManipulationsTrait;\n use ServiceTraits\\BatchSyncTrait;\n use FollowupActivityTrait;\n use LogActivityTrait;\n\n /**\n * Note Body Limit for the Old Note-Taking Tool\n *\n * @var int\n */\n private const int CLASSIC_NOTE_MAX_LENGTH = 32000;\n\n /**\n * Note Content Limit for the New Notes\n *\n * @var int\n */\n private const int ENHANCED_NOTE_MAX_LENGTH = 50000000;\n\n private const string INSTALLED_PACKAGE_ID = '033Tw0000007bKbIAI';\n\n private const int CACHE_TTL = 600;\n\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day - 86400\n\n /**\n * @var Client\n */\n protected $client;\n\n protected PayloadBuilder $payloadBuilder;\n protected QueryHandler $queryHandler;\n\n private OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n\n public function __construct(\n Client $client,\n PayloadBuilder $payloadBuilder,\n protected Dispatcher $eventDispatcher,\n private readonly CountriesMap $countriesMap,\n private readonly ProspectPhotoPathService $prospectPhotoPathService,\n ) {\n parent::__construct();\n\n $this->client = $client;\n $this->payloadBuilder = $payloadBuilder;\n $this->queryHandler = app(QueryHandler::class, [\n 'client' => $this->client,\n 'logger' => $this->logger,\n ]);\n $this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n }\n\n public function getDisplayName(): string\n {\n return 'Salesforce';\n }\n\n public function getJobDelay(): int\n {\n return 1;\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n return $user->getSocialAccount(SocialAccount::PROVIDER_SALESFORCE);\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($activity, $crmProviderId) {\n $playbook = $this->getPlaybookFromActivity($activity);\n\n if ($playbook === null) {\n $this->logger->warning('[Salesforce] Cannot verify task - no playbook found', [\n 'activity' => $activity->getId(),\n 'crm_provider_id' => $crmProviderId,\n ]);\n\n return false;\n }\n\n $objectType = $playbook->getActivityType() === Playbook::ACTIVITY_TYPE_EVENT ? 'Event' : 'Task';\n\n try {\n $record = $this->getRecord($objectType, $crmProviderId, ['Id', 'IsDeleted']);\n\n return ! empty($record) && ($record['IsDeleted'] ?? false) === false;\n } catch (HttpNotFoundException|HttpBadRequestException) {\n $this->logger->info('[Salesforce] Activity record not found during verification', [\n 'activity' => $activity->getId(),\n 'object_type' => $objectType,\n 'crm_provider_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n });\n }\n\n public function query(string $queryToRun, array $parameters = []): QueryIterator\n {\n // Due to poorly designed external calls, this method cannot be entirely removed\n return $this->queryHandler->query($queryToRun, $parameters);\n }\n\n /*=========== Organization Information ===============*/\n\n /**\n * Get a list of all the API Versions for the instance.\n *\n * @throws CrmException\n *\n * @return mixed\n *\n */\n public function getApiVersions()\n {\n $url = $this->config->crm_base_url . '/services/data';\n\n $response = $this->client->get($url);\n\n return json_decode($response->getBody(), true);\n }\n\n /**\n * Gets the valid recordTypes for a given Salesforce Object via the describe API.\n */\n private function getRecordTypes(string $crmObject): array\n {\n $url = $this->client->getObjectsUrl() . $crmObject . '/describe';\n\n $response = $this->client->get($url);\n $jsonResponse = json_decode($response->getBody(), true);\n\n $fields = [];\n foreach ($jsonResponse['recordTypeInfos'] as $row) {\n $fields[] = ['recordTypeId' => $row['recordTypeId'], 'default' => $row['defaultRecordTypeMapping']];\n }\n\n return $fields;\n }\n\n /**\n * Convert raw field data into a format compatible with CRM APIs.\n */\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return ValueNormalizer::normalize($fieldType, $fieldValue, $internal);\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n $defaultFields = ($activityType === Playbook::ACTIVITY_TYPE_TASK)\n ? FieldDefinitions::defaultTaskFields()\n : FieldDefinitions::defaultEventFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n // Setup the activity field as the default Type.\n /** @var Field $activityField */\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'Type',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK, Playbook::ACTIVITY_TYPE_EVENT];\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n\n $fieldFilter = ($activityType === Playbook::ACTIVITY_TYPE_TASK)\n ? FieldDefinitions::taskFollowupFieldsFilter()\n : FieldDefinitions::eventFollowupFieldsFilter();\n\n foreach ($fieldFilter as $eachFilter) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $eachFilter);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n /**\n * This one is now called only when ImportActivityTypes is triggered or SyncFieldMetadata executed manually\n * Regular sync now uses SharedSyncFieldsTrait -> syncSingleObjectType\n * Needs to be replaced later on\n */\n public function syncField(Field $field): void\n {\n try {\n if (FieldHelper::isCustomField($field)) {\n $query = '\n SELECT\n Id, Metadata, TableEnumOrId\n FROM\n CustomField\n WHERE\n DeveloperName = :fieldName\n AND\n TableEnumOrId = :fieldType\n AND\n NamespacePrefix = :namespacePrefix';\n\n // We need to constrain the field lookup to the object, in case it's used in multiple places.\n $objectType = \\in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true)\n ? 'activity'\n : $field->object_type;\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'fieldName' => substr($field->crm_provider_id, 0, -\\strlen('__c')),\n 'fieldType' => ucfirst($objectType),\n\n // This is used to ensure we only consider the field within the org, not installed packages.\n 'namespacePrefix' => 'null',\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n // Sync field metadata.\n $metadata = $sfField['Metadata'];\n\n $field->description = mb_strimwidth($metadata['description'] ?? '', 0, 191);\n $field->label = mb_strimwidth($metadata['label'] ?? '', 0, Field::LABEL_MAX_LENGTH);\n $field->type = $this->convertFieldType($metadata['type'], $field->getEntityName());\n $field->is_mandatory = ($metadata['required'] === true);\n $field->length = $metadata['length'];\n $field->default_value = mb_strimwidth(trim($metadata['defaultValue'] ?? '', '\"'), 0, 191);\n $field->save();\n } else {\n $query = '\n SELECT\n Id, DataType, DeveloperName, Label, Length, Description\n FROM\n FieldDefinition\n WHERE\n DurableId = :entityName';\n\n $entityName = $field->getEntityName();\n $sfFields = $this->queryHandler->metadata($query, [\n 'entityName' => $entityName,\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n $convertedType = $this->convertFieldType($sfField['DataType'], $entityName);\n $label = mb_strimwidth($sfField['Label'], 0, Field::LABEL_MAX_LENGTH);\n\n if ($field->isBusinessType()) {\n $label = 'Opportunity Type';\n }\n\n $field->description = mb_strimwidth($sfField['Description'], 0, Field::DESCRIPTION_MAX_LENGTH);\n $field->label = $label;\n $field->type = $convertedType;\n $field->length = $sfField['Length'];\n $field->save();\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n private function convertFieldType(string $from, ?string $entityName = null): string\n {\n $converter = new FieldTypeConverter();\n\n return $converter->convert($from, $entityName);\n }\n\n /**\n * @inheritdoc\n */\n public function importPicklistValues(Field $field): array\n {\n $values = [];\n $fieldValues = [];\n\n try {\n if (FieldHelper::isCustomField($field)) {\n $query = '\n SELECT\n Id, Metadata, TableEnumOrId\n FROM\n CustomField\n WHERE\n DeveloperName = :fieldName\n AND\n TableEnumOrId = :fieldType\n AND\n NamespacePrefix = :namespacePrefix';\n\n // We need to constrain the field lookup to the object, in case it's used in multiple places.\n $objectType = \\in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true) ?\n 'activity' : $field->object_type;\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'fieldName' => substr($field->crm_provider_id, 0, -\\strlen('__c')),\n 'fieldType' => ucfirst($objectType),\n // This is used to ensure we only consider the field within the org, not installed packages.\n 'namespacePrefix' => 'null',\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n $valueSet = $sfField['Metadata']['valueSet'];\n\n if ($valueSet['valueSetName'] === null) {\n // Local picklist values can be obtained easily.\n $picklistValues = $valueSet['valueSetDefinition']['value'];\n } else {\n // But for some fields, we just get the Global Value Picklist pointer so need to do more work.\n $picklistValues = $this->importGlobalValuePicklistValues($valueSet['valueSetName']);\n }\n\n // Import all active values.\n foreach ($picklistValues as $i => $sfFieldValue) {\n // Setup default value.\n if ($sfFieldValue['default']) {\n $field->update(['default_value' => $sfFieldValue['valueName']]);\n }\n\n // This comes through as null if active (lol).\n if ($sfFieldValue['isActive'] !== false) {\n $values[] = [\n 'value' => $sfFieldValue['valueName'],\n 'label' => $sfFieldValue['valueName'],\n 'sequence' => $i,\n 'is_default' => $sfFieldValue['default'],\n ];\n }\n }\n } else {\n $objectFields = $this->getObjectFields($field->object_type);\n $fieldId = $field->crm_provider_id;\n\n // Only work with our field of interest.\n $objectField = array_filter($objectFields, function ($item) use ($fieldId) {\n return $item['name'] === $fieldId;\n });\n\n $objectField = array_shift($objectField);\n if (empty($objectField['picklistValues']) === false) {\n foreach ($objectField['picklistValues'] as $i => $sfFieldValue) {\n // Skip inactive values.\n if ($sfFieldValue['active'] === false) {\n continue;\n }\n\n // Setup default value.\n if ($sfFieldValue['defaultValue']) {\n $field->update(['default_value' => $sfFieldValue['value']]);\n }\n\n $values[] = [\n 'value' => $sfFieldValue['value'],\n 'label' => $sfFieldValue['label'],\n 'sequence' => $i,\n 'is_default' => $sfFieldValue['defaultValue'],\n ];\n }\n }\n }\n\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n $value['value'] = substr($value['value'] ?? '', 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n if (($key = array_search($value['value'], $fieldsToPurge, true)) !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n // Get IDs of the values to be deleted\n $valuesToDelete = $field->values()->whereIn('value', $fieldsToPurge);\n $valuesToDeleteIds = $valuesToDelete->pluck('id');\n if (! $valuesToDeleteIds->isEmpty()) {\n $recordTypeFieldValuesRepository = app(RecordTypeFieldValuesRepository::class);\n $recordTypeFieldValuesRepository->deleteForCrmFieldValueIds($valuesToDeleteIds->toArray());\n\n // Now safely delete from crm_field_values\n $valuesToDelete->delete();\n }\n\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n return $fieldValues;\n }\n\n /**\n * Gets values from Global Value Picklists.\n */\n private function importGlobalValuePicklistValues(string $picklistName): array\n {\n $query = '\n SELECT\n Metadata\n FROM\n GlobalValueSet\n WHERE\n DeveloperName = :picklistName\n LIMIT 1';\n\n try {\n $sfValues = $this->queryHandler->metadata($query, [\n 'picklistName' => $picklistName,\n ]);\n\n // There is always 1 result at this point.\n $sfValue = $sfValues->current();\n\n return $sfValue['Metadata']['customValue'];\n } catch (NoResultsException $noResultsException) {\n // Nothing returned.\n\n return [];\n }\n }\n\n /**\n * @inheritdoc\n */\n public function syncProfileRecordTypes(): void\n {\n $objectTypes = [\n 'lead',\n 'account',\n 'contact',\n 'opportunity',\n 'task',\n 'event',\n ];\n\n foreach ($objectTypes as $objectType) {\n try {\n $crmRecordTypes = $this->getRecordTypes(ucfirst($objectType));\n\n foreach ($crmRecordTypes as $crmRecordType) {\n // If the record type is default and not the Master type, set this.\n if ($crmRecordType['default'] && $crmRecordType['recordTypeId'] !== '012000000000000AAA') {\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $crmRecordType['recordTypeId'])\n ->first();\n\n if ($recordType) {\n $this->profile->{$objectType . '_record_type_id'} = $recordType->id;\n }\n }\n }\n } catch (HttpNotFoundException $exception) {\n Log::error('No access to ' . $objectType . ' object, skipping...');\n\n // XXX: should we log this fact somewhere?\n continue;\n }\n }\n\n if ($this->profile->isDirty()) {\n $this->profile->save();\n }\n }\n\n /**\n * Gets business processes.\n */\n public function importBusinessProcesses(): void\n {\n $query = '\n SELECT\n Id, IsActive, Name, TableEnumOrId\n FROM\n BusinessProcess\n WHERE\n TableEnumOrId IN (\\'Lead\\',\\'Opportunity\\')';\n\n try {\n $sfProcesses = $this->queryHandler->query($query);\n\n // Upsert all processes for the team.\n foreach ($sfProcesses as $sfProcess) {\n /** @var BusinessProcess $businessProcess */\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $sfProcess['Id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => $sfProcess['Name'],\n 'type' => $sfProcess['TableEnumOrId'] === 'Lead' ? 'lead' : 'opportunity',\n 'is_selectable' => $sfProcess['IsActive'],\n ]);\n\n $this->importBusinessProcessStages($businessProcess);\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n /**\n * Gets business process stages.\n */\n private function importBusinessProcessStages(BusinessProcess $businessProcess): void\n {\n $query = '\n SELECT\n Metadata\n FROM\n BusinessProcess\n WHERE\n Id = :processId';\n\n try {\n $stages = [];\n $sfProcessStages = $this->queryHandler->metadata($query, [\n 'processId' => $businessProcess->crm_provider_id,\n ]);\n\n // There is always 1 result at this point.\n $sfProcessStage = $sfProcessStages->current();\n\n // Upsert all processes for the team.\n foreach ($sfProcessStage['Metadata']['values'] as $sfProcessStage) {\n $sanitizedName = urldecode($sfProcessStage['valueName']); // Must decode: \"%2C\" becomes \",\" etc.\n\n $stage = $businessProcess->crm->stages()\n // This MUST match on label because this API doesn't use API Name.\n ->where('label', $sanitizedName)\n ->where('type', $businessProcess->type)\n ->where('is_selectable', 1)\n ->first();\n\n if ($stage) {\n $stages[] = $stage->id;\n }\n }\n\n $businessProcess->stages()->sync($stages);\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n /**\n * Gets record types.\n */\n public function importRecordTypes(): void\n {\n $query = '\n SELECT\n Id, IsActive, Name, BusinessProcessId, SobjectType\n FROM\n RecordType';\n\n try {\n $sfRecordTypes = $this->queryHandler->query($query);\n\n // Upsert all record types for the process.\n foreach ($sfRecordTypes as $sfRecordType) {\n $businessProcess = null;\n if ($sfRecordType['BusinessProcessId']) {\n $businessProcess = $this->config->businessProcesses()\n ->where('crm_provider_id', $sfRecordType['BusinessProcessId'])\n ->first();\n }\n\n /** @var RecordType $recordType */\n $recordType = $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $sfRecordType['Id'],\n ], [\n 'team_id' => $this->team->id,\n 'type' => mb_strtolower($sfRecordType['SobjectType']),\n 'name' => $sfRecordType['Name'],\n 'is_selectable' => $sfRecordType['IsActive'],\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n $this->importRecordTypeFieldValues($recordType);\n }\n } catch (NoResultsException $noResultsException) {\n // Do nothing.\n }\n }\n\n /**\n * Import record type - field value mappings. This only works for standard fields.\n */\n private function importRecordTypeFieldValues(RecordType $recordType): void\n {\n try {\n $query = '\n SELECT\n Metadata\n FROM\n RecordType\n WHERE\n Id = :recordTypeId';\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'recordTypeId' => $recordType->crm_provider_id,\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n // Sync field metadata.\n $picklists = $sfField['Metadata']['picklistValues'];\n\n foreach ($picklists as $picklist) {\n $field = $this->config->fields()->where([\n 'type' => Field::TYPE_PICKLIST,\n 'object_type' => $recordType->type,\n 'crm_provider_id' => $picklist['picklist'],\n ])->first();\n\n if ($field) {\n $fieldValues = [];\n\n foreach ($picklist['values'] as $value) {\n // Must decode: \"%2C\" becomes \",\" etc.\n $fieldValue = $field->values()\n ->where('value', urldecode($value['valueName']))\n ->first();\n\n if ($fieldValue) {\n $fieldValues[] = $fieldValue->id;\n }\n }\n\n $recordType->fieldValues()->sync($fieldValues);\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n /**\n * @inheritdoc\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $params = [];\n $missingStage = null;\n if ($types === null) {\n $types = [Stage::TYPE_LEAD, Stage::TYPE_OPPORTUNITY];\n }\n\n foreach ($types as $type) {\n if ($type === Stage::TYPE_LEAD) {\n $query = '\n SELECT\n Id, ApiName, MasterLabel, SortOrder\n FROM\n LeadStatus';\n } else {\n $query = '\n SELECT\n Id, ApiName, MasterLabel, IsActive, SortOrder, DefaultProbability\n FROM\n OpportunityStage';\n }\n\n if ($missingStageName) {\n $escapedStageName = ValueNormalizer::replaceQueryWithStringLiterals($missingStageName);\n\n $query .= ' WHERE ApiName = :stageName';\n\n $params = [\n 'stageName' => $escapedStageName,\n ];\n }\n\n try {\n $sfStages = $this->queryHandler->query($query, $params);\n } catch (NoResultsException $exception) {\n $sfStages = [];\n }\n\n $missingStage = null;\n\n // Upsert all stages for the team.\n foreach ($sfStages as $sfStage) {\n $selectable = true;\n if (array_key_exists('IsActive', $sfStage)) {\n $selectable = $sfStage['IsActive'];\n }\n\n $this->restoreAnyTrashedEntity($this->config->stages(), $sfStage['Id']);\n\n $stage = $this->config->stages()->updateOrCreate([\n 'crm_provider_id' => $sfStage['Id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($sfStage['ApiName'], 0, 50),\n 'label' => mb_strimwidth($sfStage['MasterLabel'], 0, 191),\n 'type' => $type,\n 'sequence' => $sfStage['SortOrder'] ?? 0,\n 'is_selectable' => $selectable,\n 'probability' => $sfStage['DefaultProbability'] ?? null,\n ]);\n\n if ($missingStageName && $missingStageName === $sfStage['ApiName']) {\n $missingStage = $stage;\n }\n }\n\n if ($missingStageName && $missingStage === null) {\n // If they requested a stage that still doesn't exist, it must be inactive so lazy create it.\n $missingStage = $this->config->stages()->create([\n 'crm_provider_id' => Uuid::uuid4(),\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($missingStageName, 0, 50),\n 'label' => mb_strimwidth($missingStageName, 0, 191),\n 'type' => $type,\n 'sequence' => 0,\n 'is_selectable' => 0,\n ]);\n }\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int\n {\n $syncCount = 0;\n $fields = $this->getAllFieldsAsArray('lead');\n if (\\in_array('Id', $fields, true) === false) {\n return $syncCount;\n }\n\n $query = '\n SELECT ' . rtrim(implode(',', $fields), ',') . '\n FROM Lead\n WHERE LastModifiedDate > :since\n ORDER BY LastModifiedDate ASC';\n\n try {\n $sfLeads = $this->queryHandler->query($query, [\n 'since' => $since->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($sfLeads as $sfLead) {\n // Only sync if previously imported.\n if ($this->hasLead($sfLead['Id'])) {\n $this->importLead($sfLead);\n $syncCount++;\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::LEAD);\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLead(string $crmId): ?Lead\n {\n $fields = $this->getAllFieldsAsArray('lead');\n\n $sfLead = $this->getRecord('Lead', $crmId, $fields);\n\n return $this->importLead($sfLead);\n }\n\n private function importLead($crmData): ?Lead\n {\n /** @var ?Stage $stage */\n $stage = null;\n if (isset($crmData['Status'])) {\n // Get the current stage.\n $stage = $this->config\n ->stages()\n ->where('name', $crmData['Status'])\n ->where('type', Stage::TYPE_LEAD)\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_LEAD], $crmData['Status']);\n }\n }\n\n // If we have no way of importing this, just return null :(\n if ($stage === null) {\n return null;\n }\n\n $countryCode = $crmData['CountryCode'] ?? null;\n\n // Salesforce allows custom \"countries\" to be created. Disregard these.\n if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {\n $countryCode = null;\n }\n\n // If we have no country code, try to parse it from the country name.\n if ($countryCode === null && empty($crmData['Country']) !== false) {\n $countryCode = $this->convertCountryNameToCode($crmData['Country']);\n }\n\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($crmData['Phone'] ?? '', 0, 25);\n $parsedNumber = parsePhoneNumber($countryCode, $number);\n\n $mobilePhone = null;\n if (empty($crmData['MobilePhone']) === false) {\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($crmData['MobilePhone'], 0, 25);\n $mobilePhone = phone_e164($countryCode, $number);\n }\n\n $convertedDate = null;\n $convertedAccount = null;\n $convertedOpportunity = null;\n $convertedContact = null;\n\n if ($crmData['IsConverted'] == 'true') {\n $convertedDate = $crmData['ConvertedDate'];\n\n if (empty($crmData['ConvertedAccountId']) === false) {\n $convertedAccount = $this->config\n ->accounts()\n ->where('crm_provider_id', $crmData['ConvertedAccountId'])\n ->first();\n\n if ($convertedAccount === null) {\n try {\n $convertedAccount = $this->syncAccount($crmData['ConvertedAccountId']);\n } catch (HttpNotFoundException $exception) {\n // Probably the user has no permissions to access the converted data.\n }\n }\n }\n\n if (empty($crmData['ConvertedOpportunityId']) === false) {\n $convertedOpportunity = $this->config\n ->opportunities()\n ->where('crm_provider_id', $crmData['ConvertedOpportunityId'])\n ->first();\n\n if ($convertedOpportunity === null) {\n try {\n $convertedOpportunity = $this->syncOpportunity($crmData['ConvertedOpportunityId']);\n } catch (HttpNotFoundException $exception) {\n // Probably the user has no permissions to access the converted data.\n }\n }\n }\n\n if (empty($crmData['ConvertedContactId']) === false) {\n $convertedContact = $this->team\n ->crm\n ->contacts()\n ->where('crm_provider_id', $crmData['ConvertedContactId'])\n ->first();\n\n if ($convertedContact === null) {\n try {\n $convertedContact = $this->syncContact($crmData['ConvertedContactId']);\n } catch (HttpNotFoundException $exception) {\n // Probably the user has no permissions to access the converted data.\n }\n }\n }\n }\n\n if (empty($crmData['Company'])) {\n $company = 'Unknown';\n } else {\n $company = mb_strimwidth($crmData['Company'], 0, 191);\n }\n\n $domain = null;\n if (empty($crmData['Website']) === false) {\n $domain = mb_strimwidth($crmData['Website'], 0, 191);\n $domain = StringUtil::resolveDomain($domain);\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $data = [\n 'team_id' => $this->team->id,\n 'user_id' => $profile?->user_id,\n 'owner_id' => $crmData['OwnerId'] ?? '',\n 'company' => $company,\n 'domain' => $domain,\n 'name' => $crmData['Name'] ? mb_strimwidth($crmData['Name'], 0, 191) : '',\n 'title' => $crmData['Title'] ? mb_strimwidth($crmData['Title'], 0, 128) : null,\n 'email' => $crmData['Email'] ? mb_strimwidth($crmData['Email'], 0, 80) : null,\n 'phone' => $parsedNumber['phone'],\n 'ext' => $parsedNumber['ext'] ?? null,\n 'mobile_phone' => $mobilePhone,\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['Id'],\n modelType: Lead::class,\n fileName: $crmData['Id'],\n avatarText: $crmData['Name']\n ),\n 'stage_id' => $stage->id,\n 'record_type_id' => null,\n 'converted_at' => $convertedDate,\n 'converted_account_id' => $convertedAccount->id ?? null,\n 'converted_opportunity_id' => $convertedOpportunity->id ?? null,\n 'converted_contact_id' => $convertedContact->id ?? null,\n 'country_code' => $countryCode,\n 'remotely_created_at' => $createdDate,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->leads(), $crmData['Id']);\n\n /** @var Lead $lead */\n $lead = $this->config->leads()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n if ($lead->wasChanged('converted_at') && $lead->getConvertedAt() !== null) {\n $this->eventDispatcher->dispatch(new LeadConverted($lead));\n }\n\n $this->handleObjectDeletion($lead, $crmData);\n\n return $lead;\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccounts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n $fields = $this->getAllFieldsAsArray('account');\n\n if (\\in_array('Id', $fields, true) === false) {\n return $syncCount;\n }\n\n $query = '\n SELECT ' . rtrim(implode(',', $fields), ',') . '\n FROM Account\n WHERE LastModifiedDate > :since\n ORDER BY LastModifiedDate ASC';\n\n try {\n $sfAccounts = $this->queryHandler->query($query, [\n 'since' => $since->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($sfAccounts as $sfAccount) {\n // Only sync if previously imported.\n if ($this->hasAccount($sfAccount['Id'])) {\n $this->importAccount($sfAccount);\n $syncCount++;\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::ACCOUNT);\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccount(string $crmId): ?Account\n {\n $fields = $this->getAllFieldsAsArray('account');\n if (! in_array('Id', $fields, true)) {\n $this->logger->info('[Salesforce] Sync account cancelled. Fields are not available.', [\n 'crmId' => $crmId,\n 'userId' => $this->profile->getUserId(),\n ]);\n\n return null;\n }\n\n $sfAccount = $this->getRecord('Account', $crmId, $fields);\n\n return $this->importAccount($sfAccount);\n }\n\n private function importAccount($crmData): Account\n {\n $countryCode = $crmData['BillingCountryCode'] ?? $crmData['ShippingCountryCode'] ?? null;\n\n // Salesforce allows custom \"countries\" to be created. Disregard these.\n if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {\n $countryCode = null;\n }\n\n // If we have no country code, try to parse it from the country names.\n if ($countryCode === null && empty($crmData['BillingCountry']) === false) {\n $countryCode = $this->convertCountryNameToCode($crmData['BillingCountry']);\n }\n\n if ($countryCode === null && empty($crmData['ShippingCountry']) === false) {\n $countryCode = $this->convertCountryNameToCode($crmData['ShippingCountry']);\n }\n\n if (empty($crmData['Phone']) === false) {\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($crmData['Phone'], 0, 25);\n $parsedNumber = parsePhoneNumber($countryCode, $number);\n } else {\n $parsedNumber = [];\n }\n\n $industry = null;\n if (empty($crmData['Industry']) === false) {\n $industry = mb_strimwidth($crmData['Industry'], 0, 40);\n }\n\n $domain = null;\n if (empty($crmData['Website']) === false) {\n $domain = mb_strimwidth($crmData['Website'], 0, 191);\n $domain = StringUtil::resolveDomain($domain);\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $data = [\n 'team_id' => $this->team->id,\n 'user_id' => $profile?->user_id,\n 'owner_id' => $crmData['OwnerId'],\n 'name' => mb_strimwidth($crmData['Name'], 0, 191),\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['Id'],\n modelType: Account::class,\n fileName: $crmData['Id'],\n avatarText: $crmData['Name']\n ),\n 'industry' => $industry,\n 'domain' => $domain,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'ext' => $parsedNumber['ext'] ?? null,\n 'country_code' => $countryCode,\n 'remotely_created_at' => $createdDate,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->accounts(), $crmData['Id']);\n\n /** @var Account $account */\n $account = $this->config->accounts()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n $this->handleObjectDeletion($account, $crmData);\n\n return $account;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOpportunities(array $parameters, ?string $strategy = null): int\n {\n $strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);\n\n $syncCount = 0;\n $logParams = $parameters;\n $parameters['profile'] = $this->profile;\n $logParams['user'] = $this->profile->getUserId();\n\n if (count($strategies) > 1) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Multiple sync strategies used', [\n 'teamId' => $this->team->getUuid(),\n 'params' => $logParams,\n 'strategies_count' => count($strategies),\n ]);\n }\n\n foreach ($strategies as $syncStrategy) {\n $name = $syncStrategy->getStrategyName();\n\n try {\n $sfOpportunities = $syncStrategy->fetchOpportunities($parameters);\n $totalRecords = $sfOpportunities->count();\n\n foreach ($sfOpportunities as $sfOpportunity) {\n $this->importOpportunity($sfOpportunity);\n $syncCount++;\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n $this->logger->warning('[' . $this->getDisplayName() . '] No opportunities found', [\n 'teamId' => $this->team->getUuid(),\n 'name' => $name,\n 'params' => $logParams,\n 'reason' => $noResultsException->getMessage(),\n ]);\n } catch (CrmException $crmException) {\n // Nothing to sync.\n $this->logger->warning('[' . $this->getDisplayName() . '] Opportunity sync failed', [\n 'teamId' => $this->team->getUuid(),\n 'name' => $name,\n 'params' => $logParams,\n 'reason' => $crmException->getMessage(),\n ]);\n }\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::OPPORTUNITY, ['params' => $logParams]);\n\n // debug to see how if count of opportunities reaches 1000\n if ($syncCount >= 1000) {\n $this->logger->info(\n '[' . $this->getDisplayName() . '] Sync Opportunities - count warning',\n [\n 'team_id' => $this->team->getId(),\n 'params' => $logParams,\n 'count' => $syncCount,\n 'strategies_count' => count($strategies),\n 'total_records' => $totalRecords ?? null,\n ]\n );\n }\n\n return $syncCount;\n }\n\n\n /**\n * @inheritdoc\n */\n public function syncOpportunity(string $crmId): ?Opportunity\n {\n $strategy = $this->opportunitySyncStrategyResolver->resolve(\n $this->config,\n OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY\n );\n\n $parameters = [\n 'profile' => $this->profile,\n 'crm_id' => $crmId,\n ];\n\n try {\n $sfOpportunity = $strategy->fetchOpportunities($parameters);\n } catch (HttpNotFoundException $e) {\n $this->logger->info('[' . $this->getDisplayName() . '] Opportunity not found', [\n 'teamId' => $this->team->id_string,\n 'crmId' => $crmId,\n ]);\n\n return null;\n } catch (CrmException $crmException) {\n $this->logger->info('[' . $this->getDisplayName() . '] Opportunity sync failed', [\n 'teamId' => $this->team->id_string,\n 'crmId' => $crmId,\n 'exception' => $crmException->getMessage(),\n ]);\n\n return null;\n }\n\n if ($sfOpportunity instanceof ArrayIterator) {\n return $this->importOpportunity($sfOpportunity->getItems());\n }\n\n return $this->importOpportunity($sfOpportunity);\n }\n\n /**\n * @throws HttpNotFoundException\n */\n private function importOpportunity($crmData): ?Opportunity\n {\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $account = null;\n if (empty($crmData['AccountId']) === false) {\n /** @var ?Account $account */\n $account = $this->config->accounts()\n ->where('crm_provider_id', (string) $crmData['AccountId'])\n ->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmData['AccountId']);\n }\n }\n\n $userId = $profile?->getUserId() ?? $account?->getUserId();\n if ($userId === null) {\n $this->logger->error('[Salesforce] | Skip import, no user_id found', [\n 'id' => $crmData['Id'],\n ]);\n\n return null;\n }\n\n /** @var ?Stage $stage */\n $stage = null;\n if (isset($crmData['StageName'])) {\n $stage = $this->config\n ->stages()\n ->where('name', $crmData['StageName'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->orderBy('is_selectable', 'DESC')\n ->orderBy('id')\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $crmData['StageName']);\n }\n }\n\n $recordType = null;\n if (empty($crmData['RecordTypeId']) === false) {\n /** @var ?RecordType $recordType */\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $crmData['RecordTypeId'])\n ->first();\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $closeDate = null;\n if (empty($crmData['CloseDate']) === false) {\n $closeDate = Carbon::parse($crmData['CloseDate'])->format('Y-m-d');\n }\n\n $valueFieldName = 'Amount';\n if ($this->config->opportunity_value_field_id) {\n $valueFieldName = $this->config->opportunityValueField->crm_provider_id;\n }\n\n $data = [\n 'team_id' => $this->team->id,\n 'account_id' => $account->id ?? null,\n 'user_id' => $userId,\n 'owner_id' => $crmData['OwnerId'] ?? null,\n 'name' => mb_strimwidth($crmData['Name'] ?? '', 0, 128),\n 'value' => $crmData[$valueFieldName],\n 'currency_code' => CurrencyFormatter::formatCode($crmData['CurrencyIsoCode'] ?? null),\n 'close_date' => $closeDate,\n 'is_closed' => $crmData['IsClosed'],\n 'is_won' => $crmData['IsWon'],\n 'stage_id' => $stage?->id ?? null,\n 'record_type_id' => $recordType->id ?? null,\n 'remotely_created_at' => $createdDate,\n 'probability' => $crmData['Probability'] ?? null,\n 'forecast_category' => $crmData['ForecastCategoryName'] ?? null,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->opportunities(), $crmData['Id']);\n\n // Do not allow locked DB tables & other errors\n // to interrupt the process of reverting the trashed opportunities\n try {\n /** @var Opportunity $opportunity */\n $opportunity = $this->config->opportunities()\n ->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n // import external fields into crm_field_data if present\n $crmFields = $this->getOpportunitySyncableFields();\n\n $this->importOpportunityCrmFieldData($crmData, $crmFields, $opportunity->id);\n\n $this->handleObjectDeletion($opportunity, $crmData);\n\n if ($opportunity->wasRecentlyCreated) {\n MatchActivitiesToNewOpportunity::dispatch($opportunity->getId());\n }\n\n return $opportunity;\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n $this->logger->error('[Salesforce] importOpportunity failure.', [\n 'crm_provider_id' => $crmData['Id'],\n 'team_id' => $this->team->id,\n 'exception' => $exception->getMessage(),\n ]);\n\n $this->handleEntityDeletionByProviderId($this->config->opportunities(), $crmData);\n }\n\n return null;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContacts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n $fields = $this->getAllFieldsAsArray('contact');\n if (\\in_array('Id', $fields, true) === false) {\n return $syncCount;\n }\n\n $query = '\n SELECT ' . rtrim(implode(',', $fields), ',') . '\n FROM Contact\n WHERE LastModifiedDate > :since\n ORDER BY LastModifiedDate ASC';\n\n try {\n $sfContacts = $this->queryHandler->query($query, [\n 'since' => $since->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($sfContacts as $sfContact) {\n // Only sync if previously imported.\n if ($this->hasContact($sfContact['Id'])) {\n $this->importContact($sfContact);\n $syncCount++;\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::CONTACT);\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContact(string $crmId): ?Contact\n {\n $fields = $this->getAllFieldsAsArray('contact');\n if (! in_array('Id', $fields, true)) {\n $this->logger->info('[Salesforce] Sync contact cancelled. Fields are not available.', [\n 'crmId' => $crmId,\n 'userId' => $this->profile->getUserId(),\n ]);\n\n return null;\n }\n\n $sfContact = $this->getRecord('Contact', $crmId, $fields);\n\n return $this->importContact($sfContact);\n }\n\n private function importContact($crmData): Contact\n {\n $account = null;\n // Contacts may not have accounts...\n if (isset($crmData['AccountId'])) {\n $account = $this->config->accounts()\n ->where('crm_provider_id', (string) $crmData['AccountId'])\n ->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmData['AccountId']);\n }\n }\n\n $countryCode = $crmData['MailingCountryCode'] ?? null;\n\n // Salesforce allows custom \"countries\" to be created. Disregard these.\n if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {\n $countryCode = null;\n }\n\n // If we have no country code, try to parse it from the country name.\n if ($countryCode === null && empty($crmData['MailingCountry']) === false) {\n $countryCode = $this->convertCountryNameToCode($crmData['MailingCountry']);\n\n if ($countryCode === null && $account) {\n $countryCode = $account->country_code;\n }\n }\n\n $ext = null;\n $parsedNumber = [];\n if (empty($crmData['Phone']) === false) {\n $number = Str::limit($crmData['Phone'], 25, '');\n $parsedNumber = parsePhoneNumber($countryCode, $number);\n\n if (empty($parsedNumber['ext']) === false) {\n $ext = Str::limit($parsedNumber['ext'], 10, '');\n }\n }\n\n $mobileNumber = null;\n if (empty($crmData['MobilePhone']) === false) {\n $mobileNumber = Str::limit(phone_e164($countryCode, $crmData['MobilePhone']), 25, '');\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $data = [\n 'team_id' => $this->team->id,\n 'account_id' => $account->id ?? null,\n 'user_id' => $profile?->user_id,\n 'owner_id' => $crmData['OwnerId'] ?? null,\n 'name' => ($crmData['Name'] ?? null) !== null ? mb_strimwidth($crmData['Name'], 0, 100) : '',\n 'title' => ($crmData['Title'] ?? null) !== null ? mb_strimwidth($crmData['Title'], 0, 128) : null,\n 'email' => ($crmData['Email'] ?? null) !== null ? mb_strimwidth($crmData['Email'], 0, 191) : null,\n 'country_code' => $countryCode,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'ext' => $ext,\n 'mobile_phone' => $mobileNumber,\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['Id'],\n modelType: Contact::class,\n fileName: $crmData['Id'],\n avatarText: $crmData['Name']\n ),\n 'remotely_created_at' => $createdDate,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->contacts(), $crmData['Id']);\n\n /** @var Contact $contact */\n $contact = $this->config->contacts()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n $this->handleObjectDeletion($contact, $crmData);\n\n return $contact;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n $fields = [\n 'InstanceName',\n 'OrganizationType',\n 'IsSandbox',\n ];\n\n $orgValues = $this->getRecord('Organization', $this->config->crm_provider_id, $fields);\n\n $edition = null;\n switch ($orgValues['OrganizationType']) {\n case 'Developer Edition':\n $edition = Configuration::EDITION_DEVELOPER;\n\n break;\n\n case 'Professional Edition':\n $edition = Configuration::EDITION_PROFESSIONAL;\n\n break;\n\n case 'Enterprise Edition':\n $edition = Configuration::EDITION_ENTERPRISE;\n\n break;\n }\n\n $this->config->edition = $edition;\n $this->config->instance = $orgValues['InstanceName'];\n\n // XXX: How can this state be possible?\n if ($this->config->version === null) {\n $this->config->version = Client::MIN_API_VERSION;\n }\n\n $installedVersion = $this->getInstalledAppVersion();\n if ($installedVersion !== null) {\n $installedVersion = (string) $this->getInstalledAppVersion();\n }\n\n $this->config->installed_app_version = $installedVersion;\n\n $this->config->save();\n }\n\n public function getInstalledAppVersion(): ?string\n {\n try {\n $query = '\n SELECT\n SubscriberPackageVersion.MajorVersion,\n SubscriberPackageVersion.MinorVersion,\n SubscriberPackageVersion.PatchVersion,\n SubscriberPackageVersion.BuildNumber\n FROM\n InstalledSubscriberPackage\n WHERE\n SubscriberPackageId = :packageId\n ';\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'packageId' => self::INSTALLED_PACKAGE_ID,\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n // Grab version number.\n $version = $sfField['SubscriberPackageVersion']['MajorVersion'] .\n $sfField['SubscriberPackageVersion']['MinorVersion'] .\n $sfField['SubscriberPackageVersion']['PatchVersion'] .\n $sfField['SubscriberPackageVersion']['BuildNumber'];\n } catch (\\Exception) {\n $version = null;\n }\n\n return $version;\n }\n\n /**\n * Store transcripts as note.\n *\n * @throws \\Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // For SF we also check if Log Notes is enabled.\n if ($this->profile->log_notes === Profile::LOG_NOTE_NONE) {\n return;\n }\n\n if ($activity->opportunity_id && $activity->prospect === null) {\n return;\n }\n\n try {\n $transcriptionData = $this->generateTranscription($activity);\n\n $noteMaxLength = $this->profile->log_notes === Profile::LOG_NOTE_ENHANCED\n ? self::ENHANCED_NOTE_MAX_LENGTH\n : self::CLASSIC_NOTE_MAX_LENGTH;\n\n $title = 'Transcript for ';\n $title .= $activity->title ?? $activity->activity_title;\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $body = mb_strimwidth($transcriptionData, 0, $noteMaxLength);\n\n if ($activity->opportunity_id) {\n $objectId = $activity->opportunity->crm_provider_id;\n } else {\n $objectId = $activity->prospect->crm_provider_id;\n }\n\n $noteId = $this->saveNote($title, $body, $objectId);\n\n // Store crm logged id in transcription.\n $transcription = $activity->getTranscription();\n $transcription->crm_activity_id = $noteId;\n $transcription->save();\n } catch (\\Exception $e) {\n \\Sentry::captureException($e);\n }\n }\n\n public function saveNote(string $title, string $body, string $objectId, ?NoteObject $noteObject = null): ?string\n {\n $noteId = null;\n\n try {\n if ($this->profile->log_notes === Profile::LOG_NOTE_ENHANCED) {\n $noteId = $this->buildEnhancedNote($title, $body, $objectId);\n } else {\n $noteId = $this->buildClassicNote($title, $body, $objectId);\n }\n } catch (HttpNotFoundException $exception) {\n // The profile not having access to create Enhanced Notes. Set their preference to Classic.\n if ($this->profile->log_notes === Profile::LOG_NOTE_ENHANCED) {\n $this->profile->update([\n 'log_notes' => Profile::LOG_NOTE_CLASSIC,\n ]);\n }\n }\n\n return $noteId;\n }\n\n /**\n * This is using the \"Enhanced\" Notes feature, NOT the \"Notes & Attachments\" feature being deprecated.\n *\n * @url https://salesforce.stackexchange.com/questions/104408/how-can-i-create-an-account-note-or-contact-note-via-api-that-is-visible-in-sale\n */\n private function buildEnhancedNote(string $title, string $body, string $objectId): string\n {\n // Decode stored entities, escape HTML (without quoting), then convert line breaks for Salesforce formatting\n $decodedBody = html_entity_decode($body, ENT_QUOTES | ENT_HTML5);\n $sanitizedBody = htmlspecialchars($decodedBody, ENT_NOQUOTES, 'UTF-8', false);\n $content = nl2br($sanitizedBody, false);\n $note = [\n 'OwnerId' => $this->profile->crm_provider_id,\n 'Title' => $title,\n 'Content' => base64_encode($content),\n ];\n\n $noteId = $this->createRecord('ContentNote', $note);\n\n $link = [\n 'ContentDocumentId' => $noteId,\n 'LinkedEntityId' => $objectId,\n 'ShareType' => 'I',\n ];\n\n $this->createRecord('ContentDocumentLink', $link);\n\n return $noteId;\n }\n\n private function buildClassicNote(string $title, string $body, string $objectId): string\n {\n if (in_array($this->parseObjectType($objectId), [Field::OBJECT_TASK, Field::OBJECT_EVENT])) {\n $this->logger->info('[Salesforce] Summary not sent', [\n 'profile_id' => $this->profile->id,\n 'objectId' => $objectId,\n 'reason' => 'Classical Note does not support Task/Event relation',\n ]);\n\n return '';\n }\n\n $titleTrimmed = null;\n\n if (mb_strlen($title) > 80) {\n $titleTrimmed = substr($title, 0, 77) . '...';\n }\n $payload = [\n 'OwnerId' => $this->profile->crm_provider_id,\n 'IsPrivate' => false,\n 'Title' => $titleTrimmed ?? $title,\n 'Body' => $titleTrimmed ? $title . PHP_EOL . $body : $body,\n 'ParentId' => $objectId,\n ];\n\n return $this->createRecord('Note', $payload);\n }\n\n /**\n * @inheritdoc\n */\n public function find(string $name, array $scopes): array\n {\n if ($this->profile === null) {\n return [];\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $limitValues = ['limit' => $this->limit, 'offset' => $this->offset];\n $sosl = $queryBuilder->buildFindQuery($name, $scopes, $limitValues);\n\n $this->logger->info('[Salesforce] Find prospects', [\n 'profile_id' => $this->profile->id,\n 'sosl_query' => $sosl,\n 'search_string' => $name,\n 'scopes' => $scopes,\n ]);\n\n $data = Cache::remember($this->profile->id . $sosl, self::CACHE_TTL, function () use ($sosl) {\n $data = [];\n\n try {\n // Hit remote API.\n $objects = $this->queryHandler->search($sosl);\n\n // Build mapped list.\n foreach ($objects as $object) {\n $type = strtolower($object['attributes']['type']);\n\n $record = [\n 'crmId' => $object['Id'],\n 'name' => $object['Name'],\n 'prospectType' => $type,\n 'phoneNumbers' => [],\n 'crmUrl' => $this->generateProviderUrl($object['Id'], $type),\n ];\n\n switch ($type) {\n case 'lead':\n if (empty($object['Company']) === false) {\n $record['organization'] = $object['Company'];\n }\n\n if (empty($object['Title']) === false) {\n $record['title'] = $object['Title'];\n }\n\n $stage = $this->config->stages()\n ->where('type', Stage::TYPE_LEAD)\n ->where('name', $object['Status'])\n ->first();\n\n // Lazy create the stage.\n if ($stage === null) {\n $stage = $this->importStages([Stage::TYPE_LEAD], $object['Status']);\n }\n\n if ($stage) {\n $record += [\n 'stage' => [\n 'id' => $stage->id_string,\n 'name' => $stage->name,\n ],\n ];\n }\n\n if (empty($object['RecordTypeId']) === false) {\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $object['RecordTypeId'])\n ->first();\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n }\n\n break;\n\n case 'account':\n if (empty($object['Industry']) === false) {\n $record['industry'] = $object['Industry'];\n $record['detailsLine'] = $object['Industry'];\n }\n if (! empty($object['PersonEmail'])) {\n $record['detailsLine'] = $object['PersonEmail'];\n }\n\n break;\n\n case 'contact':\n // For contacts, we should try and fetch their account name too.\n if ($object['AccountId']) {\n // Cheaper to get this locally.\n $account = $this->config->accounts()\n ->where('crm_provider_id', $object['AccountId'])\n ->first(['name']);\n\n if ($account) {\n $record['organization'] = $account->name;\n }\n }\n\n if (! empty($object['IsPersonAccount']) && $object['Email']) {\n $record['detailsLine'] = $object['Email'];\n } else {\n if (empty($object['Title']) === false) {\n $record['title'] = $object['Title'];\n }\n }\n\n break;\n }\n\n // Add phone numbers to record.\n if (empty($object['Phone']) === false && $object['Phone']) {\n $record['phoneNumbers'][] = [\n 'number' => $object['Phone'],\n 'nationalFormat' => phone_national($this->profile->user->country_code, $object['Phone']),\n 'type' => 'phone',\n ];\n }\n\n if (empty($object['MobilePhone']) === false && $object['MobilePhone']) {\n $record['phoneNumbers'][] = [\n 'number' => $object['MobilePhone'],\n 'nationalFormat' => phone_national(\n $this->profile->user->country_code,\n $object['MobilePhone']\n ),\n 'type' => 'mobile',\n ];\n }\n\n $data[] = $record;\n }\n } catch (NoResultsException $e) {\n $data = [];\n }\n\n return $data;\n });\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n\n if ($crmAccountId === null) {\n return $data;\n }\n\n if ($userId) {\n $profileRepository = app(ProfileRepository::class);\n $profile = $profileRepository->findProfileByUserId($this->config, $userId);\n\n $ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;\n }\n\n try {\n // Perhaps their profile has no opportunity permissions.\n if ($this->profile === null || $this->profile->opportunity_fields === null) {\n return $data;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $query = $queryBuilder->buildFindOpportunitiesQuery();\n\n $objects = $this->queryHandler->query($query, ['accountId' => $crmAccountId]);\n\n foreach ($objects as $object) {\n $record = [\n 'crmId' => $object['Id'],\n 'name' => $object['Name'],\n 'won' => $object['IsWon'],\n 'closed' => $object['IsClosed'],\n ];\n\n $valueFieldName = 'Amount';\n if ($this->config->opportunity_value_field_id) {\n $valueFieldName = $this->config->opportunityValueField->crm_provider_id;\n }\n\n if (empty($object[$valueFieldName]) === false) {\n $currency = $object['CurrencyIsoCode'] ?? $this->config->default_currency;\n $value = formatCurrency($object[$valueFieldName], $currency);\n\n $record += [\n 'value' => $value,\n ];\n }\n\n $stage = $this->config->stages()\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->where('name', $object['StageName'])\n ->first();\n\n // Lazy create the stage.\n if ($stage === null) {\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $object['StageName']);\n }\n\n $record += [\n 'stage' => [\n 'id' => $stage->id_string,\n 'name' => $stage->name,\n ],\n ];\n\n if (empty($object['RecordTypeId']) === false) {\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $object['RecordTypeId'])\n ->first();\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n }\n\n if ($ownerId && isset($object['OwnerId']) && $object['OwnerId'] === $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n } catch (NoResultsException $e) {\n return $data;\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n public function getContactRolesFromCrm(?Carbon $since = null): array\n {\n $roles = [];\n\n if ($this->profile === null) {\n return $roles;\n }\n\n $queryBuilder = app(QueryBuilder::class, ['profile' => $this->profile]);\n\n $query = $queryBuilder->buildGetContactRolesQuery($since);\n\n try {\n $objects = $this->queryHandler->query($query);\n\n foreach ($objects as $object) {\n $roles[] = [\n 'id' => $object['Id'],\n 'contactId' => $object['ContactId'],\n 'opportunityId' => $object['OpportunityId'],\n 'ownerId' => $object['Opportunity']['OwnerId'] ?? null,\n 'isPrimary' => $object['IsPrimary'],\n 'role' => $object['Role'],\n ];\n }\n } catch (NoResultsException) {\n // Just return an empty array.\n $this->logger->info('[Salesforce] No contact roles found', [\n 'since' => $since?->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n }\n\n return $roles;\n }\n\n public function syncContactRoles(Carbon $since): int\n {\n $contactRoleRepository = app(ContactRoleRepository::class);\n\n $crmContactRoles = $this->getContactRolesFromCrm(since: $since);\n $syncCount = 0;\n $contactRoles = [];\n\n foreach ($crmContactRoles as $crmContactRole) {\n $contactRoles[] = $this->importContactRole($crmContactRole);\n $syncCount++;\n }\n\n $contactRoleRepository->saveContactRoles($contactRoles);\n\n $this->syncRemotelyDeletedContactRoles();\n\n return $syncCount;\n }\n\n private function importContactRole(array $contactRole): array\n {\n $contact = $this->config->contacts()\n ->where('crm_provider_id', $contactRole['contactId'])\n ->first();\n\n if ($contact === null) {\n $contact = $this->syncContact($contactRole['contactId']);\n }\n\n $opportunity = $this->config->opportunities()\n ->where('crm_provider_id', $contactRole['opportunityId'])\n ->first();\n\n if ($opportunity === null) {\n $opportunity = $this->syncOpportunity($contactRole['opportunityId']);\n }\n\n $role = null;\n if (! empty($contactRole['role'])) {\n $role = mb_strimwidth($contactRole['role'], 0, 191);\n }\n\n return [\n 'crm_configuration_id' => $this->config->getId(),\n 'contact_id' => $contact->getId(),\n 'crm_provider_id' => $contactRole['id'],\n 'subject_type' => ContactRole::SUBJECT_TYPE_OPPORTUNITY,\n 'subject_id' => $opportunity->getId(),\n 'is_primary' => $contactRole['isPrimary'],\n 'role' => $role,\n ];\n }\n\n protected function syncRemotelyDeletedContactRoles(): bool\n {\n try {\n $deletedRemotely = $this->queryHandler->queryDeleted('OpportunityContactRole');\n } catch (NoResultsException $e) {\n return false;\n }\n\n $deletedOpportunities = $deletedRemotely->getResults();\n $deletedIds = array_column($deletedOpportunities, 'id');\n\n $contactRoleRepository = app(ContactRoleRepository::class);\n\n foreach (array_chunk($deletedIds, self::HARD_DELETE_CHUNK) as $chunk) {\n $contactRoleRepository->deleteContactRoles($chunk);\n\n $this->logger->info('[' . $this->getDisplayName() . '] Remotely deleted opportunities synced', [\n 'teamId' => $this->team->id_string,\n 'remotelyDeletedOpportunities' => $chunk,\n 'count' => count($chunk),\n ]);\n }\n\n return true;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = $objects = [];\n\n $hasWho = \\in_array($objectType, ['lead', 'contact']);\n $playbook = $this->getPlaybook($this->profile->user);\n $fields = array_merge(\n $this->profile->getFieldsAsArray(parent::OBJECT_TASK),\n $playbook && $playbook->activityField ? [$playbook->activityField->crm_provider_id] : []\n );\n\n // Query should default to any open call for that user.\n $query = '\n SELECT ' . implode(',', array_unique($fields)) . '\n FROM Task\n WHERE OwnerId = :ownerId\n AND IsArchived = false\n AND IsDeleted = false\n AND IsClosed = false\n AND (';\n\n if ($objectType === 'account') {\n // This covers tasks tied to a related contact or opportunity too.\n $query .= '\n AccountId = :accountId';\n }\n\n if ($hasWho) {\n $query .= '\n WhoId = :whoId';\n\n // If we are also going to check on a specific opportunity, set that up.\n if ($opportunityId) {\n $query .= ' OR WhatId = :whatId';\n }\n }\n\n $query .= ' ) ORDER BY LastModifiedDate DESC';\n\n try {\n $objects = $this->queryHandler->query($query, [\n 'ownerId' => $this->profile->crm_provider_id,\n 'whoId' => $objectId,\n 'whatId' => $opportunityId,\n 'accountId' => $objectId,\n ]);\n } catch (NoResultsException $e) {\n return $data;\n } finally {\n $this->logger->debug(sprintf('[Salesforce] Found %s tasks for query \"%s\"', count($objects), $query));\n }\n\n foreach ($objects as $object) {\n $dueDate = $object['ActivityDate'] ? Carbon::parse($object['ActivityDate'])->toIso8601String() : null;\n $data[] = [\n 'crmId' => $object['Id'],\n 'subject' => $object['Subject'],\n 'due' => $dueDate,\n 'type' => $object[$playbook->activityField->crm_provider_id],\n ];\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getEvents(string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = $objects = [];\n $user = $this->profile?->user;\n if ($this->profile === null || $user === null) {\n return $data;\n }\n\n $hasWho = \\in_array($objectType, ['lead', 'contact']);\n $playbook = $this->getPlaybook($user);\n $fields = array_merge(\n $this->profile->getFieldsAsArray(parent::OBJECT_EVENT),\n $playbook && $playbook->activityField ? [$playbook->activityField->crm_provider_id] : []\n );\n\n // Query should default to any event starting in the last week and ending up until today owned by the user.\n $query = '\n SELECT ' . implode(',', array_unique($fields)) . '\n FROM Event\n WHERE OwnerId = :ownerId\n AND IsArchived = false\n AND IsAllDayEvent = false\n AND StartDateTime >= LAST_N_DAYS:7\n AND EndDateTime <= TODAY\n AND (';\n\n if ($objectType === 'account') {\n // This covers events tied to a related contact or opportunity too.\n $query .= '\n AccountId = :accountId';\n }\n\n if ($hasWho) {\n $query .= '\n WhoId = :whoId';\n\n // If we are also going to check on a specific opportunity, set that up.\n if ($opportunityId) {\n $query .= ' OR WhatId = :whatId';\n }\n }\n\n $query .= ' ) ORDER BY LastModifiedDate DESC';\n\n try {\n $objects = $this->queryHandler->query($query, [\n 'ownerId' => $this->profile->crm_provider_id,\n 'whoId' => $objectId,\n 'whatId' => $opportunityId,\n 'accountId' => $objectId,\n ]);\n } catch (NoResultsException $e) {\n return $data;\n } finally {\n $this->logger->debug(sprintf('[Salesforce] Found %s tasks for query \"%s\"', count($objects), $query));\n }\n\n foreach ($objects as $object) {\n $dueDate = $object['StartDateTime'] ? Carbon::parse($object['StartDateTime'])->toIso8601String() : null;\n\n $data[] = [\n 'crmId' => $object['Id'],\n 'subject' => $object['Subject'],\n 'due' => $dueDate,\n 'type' => $object[$playbook->activityField->crm_provider_id],\n ];\n }\n\n return $data;\n }\n\n /**\n * Try to find CRM Objects using email address\n *\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchExactlyByEmail(string $email, ?int $userId = null): ?array\n {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $sosl = $queryBuilder->buildMatchByQuery($email, Field::TYPE_EMAIL);\n if ($sosl === null) {\n return null;\n }\n\n try {\n $objects = $this->queryHandler->search($sosl);\n $objects = $this->queryHandler->prioritiseResults(\n $objects,\n $email,\n QueryHandler::PRIORITISE_EMAIL\n );\n\n $data = $this->convertCrmData($objects, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (NoResultsException $e) {\n // Try the account next.\n if ($this->profile->account_fields === null) {\n return null;\n }\n }\n\n return null;\n }\n\n public function getDomain(string $email): ?string\n {\n // SF improved search - strip the domain extension, min domain name length 4\n return $this->getCompanyNameFromEmail(email: $email, minNameLength: 4);\n }\n\n /**\n * Try to find CRM objects using domain name of the email address\n *\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByDomain(string $domain, ?int $userId = null): ?array\n {\n $companyName = $domain;\n\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $sosl = $queryBuilder->buildMatchByDomainQuery($companyName);\n\n try {\n $objects = $this->queryHandler->search($sosl);\n\n $data = $this->convertCrmData($objects, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (NoResultsException) {\n return null;\n }\n }\n\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n // Don't bother looking up numbers that are masked.\n if (str_contains($phone, '**')) {\n return null;\n }\n\n if ($this->isPhoneNumberOfTeamMember($phone)) {\n return null;\n }\n\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $phoneNational = phone_national(null, $phone) ?? '';\n $possiblePhoneFormats = collect([\n preg_replace('/\\D/', '', ltrim($phone, '0+')),\n preg_replace('/\\D/', '', $phoneNational),\n formatDashPhoneNumber($phone),\n $phoneNational,\n ])\n ->filter() // Removes null and empty strings\n ->unique()\n ->values();\n\n foreach ($possiblePhoneFormats as $phone) {\n $sosl = $queryBuilder->buildMatchByQuery($phone, Field::TYPE_PHONE);\n if ($sosl === null) {\n continue;\n }\n\n try {\n $objects = $this->queryHandler->search($sosl);\n $objects = $this->queryHandler->prioritiseResults(\n $objects,\n $phone,\n QueryHandler::PRIORITISE_PHONE\n );\n\n return $this->convertCrmData($objects, $userId);\n } catch (NoResultsException) {\n continue;\n }\n }\n\n return null;\n }\n\n private function isPhoneNumberOfTeamMember(string $phone): bool\n {\n $teamRepository = app(TeamRepository::class);\n $user = $teamRepository->findTeamMemberByPhone($this->team, $phone);\n\n if ($user instanceof User) {\n return true;\n }\n\n return false;\n }\n\n protected function getCacheKey(string $object, ?int $userId = null): ?string\n {\n $key = $this->profile->id . $object;\n $keySuffix = $this->getOwnerKeySuffix($userId);\n\n return $key . $keySuffix;\n }\n\n private function getOwnerKeySuffix(?int $userId = null): string\n {\n return $userId === null ? '' : (string) $userId;\n }\n\n /** Determine the CRM Objects which represent the call activity. */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n if ($this->profile === null) {\n return null;\n }\n\n $cacheKey = $this->getCacheKey($name, $userId);\n\n $result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $sosl = $queryBuilder->buildMatchByQuery($name, 'name');\n if ($sosl === null) {\n return false;\n }\n\n try {\n $objects = $this->queryHandler->search($sosl);\n } catch (NoResultsException $e) {\n return false;\n }\n\n $objects = $this->queryHandler->prioritiseResults(\n $objects,\n $name,\n QueryHandler::PRIORITISE_NAME\n );\n\n $data = $this->convertCrmData($objects, $userId);\n\n return (! empty(array_filter($data))) ? $data : false;\n });\n\n return is_array($result) ? $result : null;\n }\n\n /**\n * @return array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n protected function convertCrmData(QueryIterator $objects, ?int $userId = null): array\n {\n $lead = null;\n $contact = null;\n $opportunity = null;\n $account = null;\n $stage = null;\n $countryCode = null;\n\n if ($objects->count() > 0) {\n $object = $objects->current();\n\n if ($object['attributes']['type'] === 'Lead') {\n $lead = $this->importLead($object);\n\n // Lead might not be imported if the Stage is null for example.\n if ($lead) {\n $countryCode = $lead->country_code;\n $stage = $lead->stage;\n }\n } else {\n if ($object['attributes']['type'] === 'Contact') {\n $contact = $this->importContact($object);\n $account = $contact->account;\n } else {\n $account = $this->importAccount($object);\n }\n\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account) {\n $countryCode = $account->country_code;\n }\n\n try {\n $sfOpportunities = $this->findOpportunities(\n $account?->getCrmProviderId(),\n $contact?->getCrmProviderId(),\n $userId\n );\n\n // Take the first opportunity, which will be ordered as priority based on their settings.\n if (! empty($sfOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($sfOpportunities[0]['crmId']);\n $stage = $opportunity?->stage;\n }\n } catch (Exception) {\n // Nothing to see here.\n }\n }\n }\n\n return [\n $lead,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n /**\n * @inheritdoc\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n if ($stage->type === Stage::TYPE_LEAD) {\n $objectType = 'Lead';\n $objectId = $crmObject->crm_provider_id;\n $objectStageType = 'Status';\n } else {\n $objectType = 'Opportunity';\n $objectId = $crmObject->crm_provider_id;\n $objectStageType = 'StageName';\n }\n\n $headers = [];\n if ($this->config->trigger_assignment_rules === false) {\n // @see: https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/headers_autoassign.htm\n $headers = [\n 'Sforce-Auto-Assign' => 'false',\n ];\n }\n\n $this->updateRecord($objectType, $objectId, [$objectStageType => $stage->name], $headers);\n }\n\n public function parseObjectType(string $objectId): string\n {\n if (Str::startsWith($objectId, '001')) {\n return 'account';\n }\n\n if (Str::startsWith($objectId, '003')) {\n return 'contact';\n }\n\n if (Str::startsWith($objectId, '00Q')) {\n return 'lead';\n }\n\n if (Str::startsWith($objectId, '006')) {\n return 'opportunity';\n }\n\n if (Str::startsWith($objectId, '00U')) {\n return 'event';\n }\n\n if (Str::startsWith($objectId, '00T')) {\n return 'task';\n }\n\n throw new \\InvalidArgumentException('Unsupported Object Type');\n }\n\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, ['profile' => $this->profile]);\n $query = $queryBuilder->buildGetUsersQuery($userToSearch);\n\n try {\n $salesforceUsers = $this->queryHandler->query($query, [\n 'active' => true,\n ]);\n } catch (NoResultsException $e) {\n $this->logger->info('[Salesforce] Sync Profiles. No users found', [\n 'query' => $query,\n 'error' => $e->getMessage(),\n ]);\n\n return null;\n }\n\n $teamRepository = app(TeamRepository::class);\n $customRules = $this->getCustomProfileRules($teamRepository);\n\n foreach ($salesforceUsers as $crmUser) {\n if ($crmUser['Email'] === null) {\n continue;\n }\n\n if (! $this->customProfileValidation($crmUser, $customRules)) {\n continue;\n }\n\n $user = $teamRepository->findActiveTeamMemberByEmail($this->team, $crmUser['Email']);\n\n if (! $user instanceof User) {\n continue;\n }\n\n $edition = $crmUser['UserPreferencesLightningExperiencePreferred']\n ? Profile::EDITION_LIGHTNING\n : Profile::EDITION_CLASSIC;\n\n $profileRepository = app(ProfileRepository::class);\n $profile = $profileRepository->updateOrCreateProfile(\n $user,\n [\n 'crm_configuration_id' => $this->config->getId(),\n 'crm_provider_id' => $crmUser['Id'],\n ],\n [\n 'user_id' => $user->getId(),\n 'edition' => $edition,\n 'has_external_cti' => ! empty($crmUser['CallCenterId']),\n 'crm_profile_id' => $crmUser['ProfileId'],\n ]\n );\n\n if ($userToSearch instanceof User && $userToSearch->getId() === $user->getId()) {\n return $profile;\n }\n }\n\n // Clean up inactive profiles\n try {\n $this->archiveInactiveProfiles();\n } catch (\\Exception $e) {\n $this->logger->warning('[Salesforce] Profile archiving failed', [\n 'teamId' => $this->team->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $url = null;\n\n // For Salesforce it's easy, we just point every object to the apex domain and they handle it.\n switch ($objectType) {\n case 'lead':\n case 'account':\n case 'contact':\n case 'opportunity':\n case 'task':\n case 'event':\n case 'activity':\n\n $url = $this->config->crm_base_url . '/' . $providerId;\n\n break;\n }\n\n return $url;\n }\n\n public function buildTaskSearchFields(): array\n {\n return ['Id', 'WhoId', 'WhatId', 'AccountId'];\n }\n\n public function getTaskByFilterConditions(\n array $fields,\n array $filters,\n bool $bulkSearch = false,\n bool $strictFilters = true\n ): ?array {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $query = $queryBuilder->buildSearchTaskQuery($fields, $filters, $bulkSearch, $strictFilters);\n\n try {\n if (! $bulkSearch) {\n $objects = $this->queryHandler->query($query, $filters);\n if ($objects->count() === 1) {\n return $objects->current();\n }\n }\n\n if ($bulkSearch) {\n $objects = $this->queryHandler->query($query);\n $records = [];\n foreach ($objects as $record) {\n $key = $record[end($fields)];\n $records[$key] = $record;\n }\n\n return $records;\n }\n } catch (\\Exception $e) {\n $this->logger->info('[Salesforce] Failed to execute query', [\n 'query' => $query,\n 'error' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function mapCrmObjects(array $task): array\n {\n $activityData = [];\n\n if (! empty($task['WhoId'])) {\n $type = $this->parseObjectType($task['WhoId']);\n $activityData[$type] = $task['WhoId'];\n }\n if (! empty($task['AccountId'])) {\n $activityData['account'] = $task['AccountId'];\n }\n if (! empty($task['WhatId'])) {\n $activityData['opportunity'] = $task['WhatId'];\n }\n\n return $activityData;\n }\n\n /**\n * Get SF task by Outreach call id.\n */\n public function getTaskByFilter(\n string $activityFieldType,\n array $filters,\n string $operator = '=',\n array $additionalFields = []\n ): ?array {\n $data = [];\n\n try {\n // Default (base) fields.\n $fields = ['Id', 'Subject', 'Description', 'ActivityDate', 'WhoId', 'WhatId', $activityFieldType];\n\n foreach ($additionalFields as $additionalField) {\n $fields[] = $additionalField->crm_provider_id;\n }\n\n $fields = array_unique($fields);\n\n // Find task with the same Outreach id as the call id.\n $query = 'SELECT ' . implode(',', $fields) . '\n FROM Task\n WHERE IsArchived = false AND IsDeleted = false';\n\n foreach ($filters as $key => $value) {\n $key = preg_quote($key, '/');\n $key = str_replace(['\\'', '\"'], '', $key);\n // Prepare the substitution.\n $strKey = \":$key\";\n\n $query .= \" AND $key $operator $strKey\";\n }\n\n $query .= ' ORDER BY LastModifiedDate DESC LIMIT 1';\n\n $objects = $this->queryHandler->query($query, $filters);\n\n // There should be only one task related to this call if any.\n if ($objects->count() === 1) {\n $object = $objects->current();\n\n $dueDate = $object['ActivityDate'] ? Carbon::parse($object['ActivityDate'])->toIso8601String() : null;\n\n $data = array_merge($object, [\n 'crmId' => $object['Id'],\n 'subject' => $object['Subject'],\n 'summary' => $object['Description'],\n 'due' => $dueDate,\n 'Type' => $object[$activityFieldType],\n ]);\n }\n } catch (NoResultsException $e) {\n // Filters don't match any records.\n } catch (ServiceUnavailableException $serviceUnavailableException) {\n // Service cannot be queried. We should probably log this.\n }\n\n return $data;\n }\n\n /**\n * Get Salesforce fields including datetime fields\n *\n * @param $objectType\n */\n private function getAllFieldsAsArray($objectType): array\n {\n $basicFields = [];\n // Not all users have access to all object fields.\n if ($this->profile->{$objectType . '_fields'}) {\n $basicFields = explode(',', $this->profile->{$objectType . '_fields'});\n }\n\n $extraFields = [\n 'CreatedDate',\n 'LastModifiedDate',\n 'IsDeleted',\n ];\n\n if ($objectType === self::OBJECT_OPPORTUNITY\n && $this->config->opportunity_value_field_id\n && ! in_array($this->config->opportunityValueField->crm_provider_id, $basicFields)\n ) {\n $extraFields[] = $this->config->opportunityValueField->crm_provider_id;\n }\n\n return array_unique(array_merge($basicFields, $extraFields));\n }\n\n /**\n * Generate transcription for activity description.\n */\n private function generateTranscription(Activity $activity): string\n {\n if (! ($this->config->store_transcript)) {\n // If sending transcription to activity toggle is disabled\n return '';\n }\n\n return $this->transcriptionService\n ->findTranscriptionByActivity($activity)\n ->map(static function (array $transcriptionSegment): string {\n return $transcriptionSegment['formattedStartsAt'] . ' | ' . $transcriptionSegment['transcript'];\n })\n ->implode(PHP_EOL);\n }\n\n /**\n * Find related Salesforce event based on activity data\n *\n * @return array<string>\n */\n public function fetchRelatedActivity(Activity $activity): array\n {\n $this->logger->info('[Salesforce] Searching for related activity', [\n 'activityId' => $activity->getUuid(),\n 'ownerId' => $this->profile?->crm_provider_id,\n ]);\n\n $sfEvent = $this->fetchRelatedEvent($activity);\n if (empty($sfEvent)) {\n $this->logger->info('[Salesforce] No related activity found', [\n 'activityId' => $activity->getUuid(),\n 'ownerId' => $this->profile?->crm_provider_id,\n 'account' => $activity->hasAccount()\n ? $activity->getAccount()->getCrmProviderId()\n : null,\n ]);\n\n return [];\n }\n\n return $sfEvent;\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n if ($activity->isTypeConference() === false) {\n return null;\n }\n\n if ($activity->hasActualStartTime() === false && $activity->hasScheduledStartTime() === false) {\n return null;\n }\n\n if (! $activity->hasProspect()) {\n $this->logger->info('[Salesforce] Skip look up, Activity not linked to Lead, Contact or Account', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n $playbook = $this->getPlaybook($activity->getUser());\n if ($playbook !== null && $playbook->getActivityType() === Playbook::ACTIVITY_TYPE_TASK) {\n $this->logger->info('[Salesforce] Skip auto-sync for task-based playbook', [\n 'activityUuid' => $activity->getUuid(),\n 'playbookId' => $playbook->getId(),\n 'playbookType' => $playbook->getActivityType(),\n ]);\n\n return null;\n }\n\n try {\n $sfEvent = $this->fetchRelatedActivity($activity);\n if (empty($sfEvent)) {\n return null;\n }\n\n [$activityField, $activityType] = $this->resolveActivityTypeFromEvent($activity, $sfEvent);\n\n $this->logger->info('[Salesforce] Found related activity', [\n 'activityId' => $activity->getUuid(),\n 'sfEvent' => $sfEvent['Id'],\n 'activityFieldName' => $activityField,\n 'crmActivityType' => ($activityField !== null && isset($sfEvent[$activityField]))\n ? $sfEvent[$activityField]\n : null,\n 'activityType' => $activityType,\n ]);\n\n $userId = $this->findRelatedActivityUserId($activity, $sfEvent);\n\n if ($activity->getUserId() !== $userId) {\n $this->logger->info('[Salesforce] Updating meeting owner', [\n 'activityId' => $activity->getUuid(),\n 'oldUserId' => $activity->getUserId(),\n 'newUserId' => $userId,\n ]);\n }\n\n $this->updateSfEventDescription($activity, $sfEvent);\n\n $activity->update([\n 'user_id' => $userId,\n 'crm_provider_id' => $sfEvent['Id'],\n 'playbook_category_id' => $activityType->id ?? $activity->getCategory()?->getId(),\n ]);\n\n $this->logger->info('[Salesforce] Activity updated', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return $activity;\n } catch (\\Exception $exception) {\n \\Sentry::captureException($exception);\n\n throw $exception;\n }\n }\n\n /**\n * @param array<string, mixed> $sfEvent\n *\n * @return array{0: string|null, 1: mixed}\n */\n private function resolveActivityTypeFromEvent(Activity $activity, array $sfEvent): array\n {\n $activityField = $this->getActivityFieldName($activity);\n $activityType = null;\n\n if ($activityField !== null && ! empty($sfEvent[$activityField])) {\n $playbook = $this->getPlaybook($activity->getUser());\n $activityType = $this->getPlaybookCategory($playbook, strval($sfEvent[$activityField]));\n }\n\n return [$activityField, $activityType];\n }\n\n /**\n * @param array<string> $sfEvent\n */\n private function findRelatedActivityUserId(Activity $activity, array $sfEvent): int\n {\n $userId = $activity->getUserId();\n\n if (empty($sfEvent['OwnerId']) === false) {\n $profile = $this\n ->config\n ->profiles()\n ->where('crm_provider_id', $sfEvent['OwnerId'])\n ->get()\n ->filter(static function (Profile $profile) use ($activity): bool {\n if (! $activity->isTypeConference()) {\n return ! empty($profile->user) ? $profile->user->isStatusActive() : false;\n }\n\n $participants = $activity->getParticipants();\n\n return ! empty($profile->user)\n ? $profile->user->isStatusActive()\n && $profile->user->hasPermission(PermissionEnum::RECORD_MEETING)\n && $participants->contains('user_id', $profile->user_id)\n : false;\n })\n ->first();\n\n if ($profile) {\n $userId = $profile->user_id;\n }\n }\n\n return $userId;\n }\n\n /**\n * @param array<string, mixed> $sfEvent\n */\n private function updateSfEventDescription(Activity $activity, array $sfEvent): void\n {\n try {\n if (str_contains($sfEvent['Description'], $activity->id_string)) {\n return;\n }\n\n $payload = [\n 'Description' => $sfEvent['Description']\n . PHP_EOL\n . PHP_EOL\n . (new DecorateActivity())->generateDescription($activity),\n ];\n\n $this->logger->info('[Salesforce] Update record', [\n 'activityId' => $activity->getUuid(),\n 'sfEvent' => $sfEvent['Id'],\n 'payload' => $payload,\n ]);\n\n $payload = array_merge(\n $payload,\n $this->payloadBuilder->fetchCustomFieldData($activity, Field::OBJECT_EVENT)\n );\n\n $this->updateRecord('Event', $sfEvent['Id'], $payload);\n } catch (\\Exception) {\n $this->logger->error('[Salesforce] Failed to update record', [\n 'activityUuid' => $activity->getUuid(),\n 'sfEvent' => $sfEvent['Id'],\n ]);\n }\n }\n\n /**\n * Returns the most recently modified Event within time range (if any).\n *\n * @return array|null An Event record from Salesforce.\n */\n private function fetchRelatedEvent(Activity $activity): ?array\n {\n $ownerId = $this->profile?->crm_provider_id;\n if ($ownerId === null) {\n return [];\n }\n\n /** @var ?Carbon $from */\n /** @var ?Carbon $to */\n [$from, $to] = $this->getFromToDates($activity);\n\n try {\n $whoId = null;\n $hasWho = $activity->lead_id || $activity->contact_id;\n if ($hasWho) {\n $whoId = $activity->hasLead()\n ? $activity->getLead()->crm_provider_id\n : $activity->getContact()->crm_provider_id;\n }\n\n if ($hasWho === false && $activity->account_id === null) {\n return null;\n }\n\n $query = $this->buildFetchRelatedEventQuery($activity);\n\n $objects = $this->queryHandler->query($query, [\n 'ownerId' => $ownerId,\n 'whoId' => $whoId,\n 'whatId' => $activity->hasOpportunity() ? $activity->getOpportunity()->crm_provider_id : null,\n 'accountId' => $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null,\n 'from' => $from?->format('Y-m-d\\TH:i:s\\Z'),\n 'to' => $to?->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($objects as $object) {\n return $object;\n }\n } catch (NoResultsException $e) {\n return [];\n }\n\n return [];\n }\n\n private function getFromToDates(Activity $activity): array\n {\n $from = null;\n $to = null;\n\n /** @var ?CalendarEvent $calendarEvent */\n $calendarEvent = $activity->calendarEvent()->first();\n if ($calendarEvent !== null) {\n $from = $calendarEvent->getStartTime();\n $to = $calendarEvent->getEndTime();\n }\n\n // For non-calendar imported activities\n // Also double check if calendar event dates could be null?\n // If null use what we've got so far\n if ($from === null || $to === null) {\n $from = $activity->hasScheduledStartTime()\n ? $activity->getScheduledStartTime()\n : $activity->getActualStartTime();\n $to = $activity->hasScheduledEndTime()\n ? $activity->getScheduledEndTime()->addMinutes(15)\n : $activity->getActualEndTime();\n }\n\n return [$from, $to];\n }\n\n /**\n * Determines the appropriate activity field name for querying Salesforce events.\n *\n * This method follows a hierarchy to determine the field name:\n * 1. Uses the playbook's activity field if it exists and is in the profile's accessible fields\n * 2. Falls back to the default activity field if the profile has no event fields configured\n * 3. Returns null if no suitable field is found\n *\n * @param Activity $activity The activity to determine the field for\n *\n * @return string|null The field name to use in queries, or null if none is available\n */\n private function getActivityFieldName(Activity $activity): ?string\n {\n if ($this->profile === null) {\n $this->logger->warning('[Salesforce] Cannot determine activity field - profile not found', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n $profileEventFields = $this->profile->getFieldsAsArray('event');\n\n if (empty($profileEventFields)) {\n $defaultActivityField = $this->getDefaultActivityField(Field::OBJECT_EVENT);\n $defaultFieldName = $defaultActivityField?->getAttribute('crm_provider_id');\n // Profile not yet synced — fall back to the default activity field.\n // There is a small chance that the profile won't have Default Activity Type field access\n // in which case the query will fail.\n // This is however an edge case and should be reviewed for profile sync issues.\n Sentry::withScope(function (\\Sentry\\State\\Scope $scope) use ($defaultFieldName): void {\n $scope->setContext('details', [\n 'profileId' => $this->profile->id,\n 'defaultField' => $defaultFieldName,\n ]);\n Sentry::captureMessage(\n '[Salesforce] Profile event fields empty, falling back to default activity field.',\n \\Sentry\\Severity::warning()\n );\n });\n\n return $defaultFieldName;\n }\n\n $playbook = $this->getPlaybook($activity->getUser());\n\n if (! is_null($playbook) && ! is_null($playbook->getActivityField())) {\n $playbookFieldName = $playbook->getActivityField()->getAttribute('crm_provider_id');\n\n if (in_array($playbookFieldName, $profileEventFields, true)) {\n return $playbookFieldName;\n }\n\n $this->logger->warning('[Salesforce] Playbook activity field not found in profile fields', [\n 'activityId' => $activity->getUuid(),\n 'playbookField' => $playbookFieldName,\n 'profileId' => $this->profile->id,\n ]);\n }\n\n return null;\n }\n\n private function buildFetchRelatedEventQuery(Activity $activity): string\n {\n $hasWho = $activity->lead_id || $activity->contact_id;\n\n $activityFieldName = $this->getActivityFieldName($activity);\n $fields = array_filter(['Id', 'Description', 'OwnerId', $activityFieldName]);\n\n $ownerCondition = '(OwnerId = :ownerId OR CreatedById = :ownerId)';\n\n $query = '\n SELECT ' . implode(',', $fields) . '\n FROM Event\n WHERE ' . $ownerCondition . '\n AND IsArchived = false\n AND IsAllDayEvent = false\n AND StartDateTime >= :from\n AND EndDateTime <= :to\n AND (';\n\n $operator = '';\n if ($activity->account_id) {\n // This covers events tied to a related contact or opportunity too.\n $query .= 'AccountId = :accountId';\n\n $operator = ' OR ';\n }\n\n if ($hasWho) {\n $query .= $operator . 'WhoId = :whoId';\n\n // If we are also going to check on a specific opportunity, set that up.\n if ($activity->opportunity_id) {\n $query .= ' OR WhatId = :whatId';\n }\n }\n\n $query .= ') ORDER BY LastModifiedDate DESC';\n\n return $query;\n }\n\n public function fetchProspect(array $task): array\n {\n $lead = $account = $opportunity = $contact = $stage = $countryCode = null;\n $externalId = $task['WhoId'] ?? null;\n\n // Lead or Contact\n if ($externalId) {\n try {\n [$lead, $account, $opportunity, $contact, $stage, $countryCode] = $this->parseRecords($externalId);\n } catch (\\InvalidArgumentException $exception) {\n // Invalid object type.\n }\n }\n\n // If we happen to know the opportunity or account from the Task, figure that out.\n if (empty($task['WhatId']) === false) {\n // WhatId could be either Account ID or Opportunity ID.\n // If WhatId is Opportunity ID, get the opportunity and stage from the CRM.\n try {\n [, $account, $opportunity, , $stage, ] = $this->parseRecords($task['WhatId']);\n } catch (\\InvalidArgumentException $exception) {\n // Invalid object type.\n }\n }\n\n return [$lead, $account, $opportunity, $contact, $stage, $countryCode];\n }\n\n /**\n * Save activity transcription summary as note\n */\n public function saveTranscriptionSummaryAsNote(\n ActivityContract $activity,\n string $title,\n string $body,\n ?string $objectId,\n ?NoteObject $noteObject = null,\n ): ?string {\n return $this->saveNote($title, $body, (string) $objectId);\n }\n\n public function getObjectByFilterConditions(string $objectType, array $fields, array $filters): ?array\n {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $query = $queryBuilder->buildObjectSearchQuery($objectType, $fields, $filters);\n\n try {\n $objects = $this->queryHandler->query($query, $filters);\n if ($objects->count() === 1) {\n return $objects->current();\n }\n } catch (\\Exception $e) {\n $this->logger->info('[Salesforce] Failed to execute query', [\n 'query' => $query,\n 'error' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n private function getCustomProfileRules(TeamRepository $teamRepository): array\n {\n $teamSettings = $teamRepository->getTeamSetting($this->team, 'custom_profile_validation');\n\n if ($teamSettings instanceof TeamSettings && $teamSettings->getValueType() === 'array') {\n $customRules = json_decode($teamSettings->getValue(), true);\n if (is_array($customRules)) {\n return $customRules;\n }\n }\n\n return [];\n }\n\n private function customProfileValidation(array $crmUser, array $customRules): bool\n {\n foreach ($customRules as $customRule) {\n if ($crmUser[$customRule['field']] !== $customRule['value']) {\n return false;\n }\n }\n\n return true;\n }\n\n /**\n * When syncing Contact / Lead / Account / Opportunity / Stage crm entities,\n * validate and restore locally trashed objects,\n * before updating them. Objects are identified by CrmProviderId\n */\n private function restoreAnyTrashedEntity(HasMany $targetEntity, string $crmProviderId): void\n {\n $recordExists = $targetEntity->withTrashed()->where(['crm_provider_id' => $crmProviderId])->first();\n if ($recordExists && $recordExists->trashed()) {\n $recordExists->restore();\n }\n }\n\n #[\\Override] public function supportsNotes(): bool\n {\n return true;\n }\n\n private function getOwnerProfile(?string $ownerId): ?Profile\n {\n if ($ownerId === null) {\n return null;\n }\n\n return $this->config->profiles()\n ->where('crm_provider_id', $ownerId)\n ->first();\n }\n}","depth":2,"bounds":{"left":0.25531915,"top":0.42697525,"width":0.29089096,"height":0.57302475},"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Salesforce;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse Illuminate\\Database\\Eloquent\\Relations\\HasMany;\nuse Illuminate\\Events\\Dispatcher;\nuse Illuminate\\Support\\Facades\\Cache;\nuse Illuminate\\Support\\Facades\\Log;\nuse Illuminate\\Support\\Str;\nuse Jiminny\\Component\\Country\\CountriesMap;\nuse Jiminny\\Contracts\\Acl\\PermissionEnum;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\FetchRelatedActivityInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\ImportsBusinessProcessesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\SalesforceBatchSyncInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\SalesforceInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteNoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SearchTaskInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SendSummaryToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SupportsObjectTypeParseInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmProfileRecordTypesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Enums\\CrmObject;\nuse Jiminny\\Events\\Activities\\Crm\\LeadConverted;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpBadRequestException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Exceptions\\NoResultsException;\nuse Jiminny\\Exceptions\\ServiceUnavailableException;\nuse Jiminny\\Jobs\\Crm\\NoteObject;\nuse Jiminny\\Jobs\\Crm\\MatchActivitiesToNewOpportunity;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Calendar\\CalendarEvent;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Contracts\\ActivityContract;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Configuration;\nuse Jiminny\\Models\\Crm\\ContactRole;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Crm\\RecordType;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\TeamSettings;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\ContactRoleRepository;\nuse Jiminny\\Repositories\\Crm\\FieldRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Repositories\\Crm\\RecordTypeFieldValuesRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\Helpers\\ArrayIterator;\nuse Jiminny\\Services\\Crm\\MatchDomainByEmailInterface;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Services\\Crm\\ResolveCompanyNameByEmailTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\Fields\\FieldHelper;\nuse Jiminny\\Services\\Crm\\Salesforce\\Fields\\FieldTypeConverter;\nuse Jiminny\\Services\\Crm\\Salesforce\\Fields\\ValueNormalizer;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\FollowupActivityTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\LogActivityTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\RecordManipulationsTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\SyncFieldsTrait;\nuse Jiminny\\Utils\\CurrencyFormatter;\nuse Jiminny\\Utils\\StringUtil;\nuse Ramsey\\Uuid\\Uuid;\nuse Sentry\\Laravel\\Facade as Sentry;\n\nclass Service extends BaseService implements\n SalesforceInterface,\n SalesforceBatchSyncInterface,\n SyncCrmEntitiesInterface,\n SyncCrmProfileRecordTypesInterface,\n ImportsBusinessProcessesInterface,\n RemoteEntityManipulationInterface,\n FetchRelatedActivityInterface,\n SendSummaryToCrmInterface,\n MatchDomainByEmailInterface,\n SearchTaskInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n SupportsObjectTypeParseInterface,\n RemoteNoteEntityManipulationInterface,\n VerifyTaskExistsInterface\n{\n use ResolveCompanyNameByEmailTrait;\n use SyncFieldsTrait;\n use DeleteObjectsTrait;\n use RecordManipulationsTrait;\n use ServiceTraits\\BatchSyncTrait;\n use FollowupActivityTrait;\n use LogActivityTrait;\n\n /**\n * Note Body Limit for the Old Note-Taking Tool\n *\n * @var int\n */\n private const int CLASSIC_NOTE_MAX_LENGTH = 32000;\n\n /**\n * Note Content Limit for the New Notes\n *\n * @var int\n */\n private const int ENHANCED_NOTE_MAX_LENGTH = 50000000;\n\n private const string INSTALLED_PACKAGE_ID = '033Tw0000007bKbIAI';\n\n private const int CACHE_TTL = 600;\n\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day - 86400\n\n /**\n * @var Client\n */\n protected $client;\n\n protected PayloadBuilder $payloadBuilder;\n protected QueryHandler $queryHandler;\n\n private OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n\n public function __construct(\n Client $client,\n PayloadBuilder $payloadBuilder,\n protected Dispatcher $eventDispatcher,\n private readonly CountriesMap $countriesMap,\n private readonly ProspectPhotoPathService $prospectPhotoPathService,\n ) {\n parent::__construct();\n\n $this->client = $client;\n $this->payloadBuilder = $payloadBuilder;\n $this->queryHandler = app(QueryHandler::class, [\n 'client' => $this->client,\n 'logger' => $this->logger,\n ]);\n $this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n }\n\n public function getDisplayName(): string\n {\n return 'Salesforce';\n }\n\n public function getJobDelay(): int\n {\n return 1;\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n return $user->getSocialAccount(SocialAccount::PROVIDER_SALESFORCE);\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($activity, $crmProviderId) {\n $playbook = $this->getPlaybookFromActivity($activity);\n\n if ($playbook === null) {\n $this->logger->warning('[Salesforce] Cannot verify task - no playbook found', [\n 'activity' => $activity->getId(),\n 'crm_provider_id' => $crmProviderId,\n ]);\n\n return false;\n }\n\n $objectType = $playbook->getActivityType() === Playbook::ACTIVITY_TYPE_EVENT ? 'Event' : 'Task';\n\n try {\n $record = $this->getRecord($objectType, $crmProviderId, ['Id', 'IsDeleted']);\n\n return ! empty($record) && ($record['IsDeleted'] ?? false) === false;\n } catch (HttpNotFoundException|HttpBadRequestException) {\n $this->logger->info('[Salesforce] Activity record not found during verification', [\n 'activity' => $activity->getId(),\n 'object_type' => $objectType,\n 'crm_provider_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n });\n }\n\n public function query(string $queryToRun, array $parameters = []): QueryIterator\n {\n // Due to poorly designed external calls, this method cannot be entirely removed\n return $this->queryHandler->query($queryToRun, $parameters);\n }\n\n /*=========== Organization Information ===============*/\n\n /**\n * Get a list of all the API Versions for the instance.\n *\n * @throws CrmException\n *\n * @return mixed\n *\n */\n public function getApiVersions()\n {\n $url = $this->config->crm_base_url . '/services/data';\n\n $response = $this->client->get($url);\n\n return json_decode($response->getBody(), true);\n }\n\n /**\n * Gets the valid recordTypes for a given Salesforce Object via the describe API.\n */\n private function getRecordTypes(string $crmObject): array\n {\n $url = $this->client->getObjectsUrl() . $crmObject . '/describe';\n\n $response = $this->client->get($url);\n $jsonResponse = json_decode($response->getBody(), true);\n\n $fields = [];\n foreach ($jsonResponse['recordTypeInfos'] as $row) {\n $fields[] = ['recordTypeId' => $row['recordTypeId'], 'default' => $row['defaultRecordTypeMapping']];\n }\n\n return $fields;\n }\n\n /**\n * Convert raw field data into a format compatible with CRM APIs.\n */\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return ValueNormalizer::normalize($fieldType, $fieldValue, $internal);\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n $defaultFields = ($activityType === Playbook::ACTIVITY_TYPE_TASK)\n ? FieldDefinitions::defaultTaskFields()\n : FieldDefinitions::defaultEventFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n // Setup the activity field as the default Type.\n /** @var Field $activityField */\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'Type',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK, Playbook::ACTIVITY_TYPE_EVENT];\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n\n $fieldFilter = ($activityType === Playbook::ACTIVITY_TYPE_TASK)\n ? FieldDefinitions::taskFollowupFieldsFilter()\n : FieldDefinitions::eventFollowupFieldsFilter();\n\n foreach ($fieldFilter as $eachFilter) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $eachFilter);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n /**\n * This one is now called only when ImportActivityTypes is triggered or SyncFieldMetadata executed manually\n * Regular sync now uses SharedSyncFieldsTrait -> syncSingleObjectType\n * Needs to be replaced later on\n */\n public function syncField(Field $field): void\n {\n try {\n if (FieldHelper::isCustomField($field)) {\n $query = '\n SELECT\n Id, Metadata, TableEnumOrId\n FROM\n CustomField\n WHERE\n DeveloperName = :fieldName\n AND\n TableEnumOrId = :fieldType\n AND\n NamespacePrefix = :namespacePrefix';\n\n // We need to constrain the field lookup to the object, in case it's used in multiple places.\n $objectType = \\in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true)\n ? 'activity'\n : $field->object_type;\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'fieldName' => substr($field->crm_provider_id, 0, -\\strlen('__c')),\n 'fieldType' => ucfirst($objectType),\n\n // This is used to ensure we only consider the field within the org, not installed packages.\n 'namespacePrefix' => 'null',\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n // Sync field metadata.\n $metadata = $sfField['Metadata'];\n\n $field->description = mb_strimwidth($metadata['description'] ?? '', 0, 191);\n $field->label = mb_strimwidth($metadata['label'] ?? '', 0, Field::LABEL_MAX_LENGTH);\n $field->type = $this->convertFieldType($metadata['type'], $field->getEntityName());\n $field->is_mandatory = ($metadata['required'] === true);\n $field->length = $metadata['length'];\n $field->default_value = mb_strimwidth(trim($metadata['defaultValue'] ?? '', '\"'), 0, 191);\n $field->save();\n } else {\n $query = '\n SELECT\n Id, DataType, DeveloperName, Label, Length, Description\n FROM\n FieldDefinition\n WHERE\n DurableId = :entityName';\n\n $entityName = $field->getEntityName();\n $sfFields = $this->queryHandler->metadata($query, [\n 'entityName' => $entityName,\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n $convertedType = $this->convertFieldType($sfField['DataType'], $entityName);\n $label = mb_strimwidth($sfField['Label'], 0, Field::LABEL_MAX_LENGTH);\n\n if ($field->isBusinessType()) {\n $label = 'Opportunity Type';\n }\n\n $field->description = mb_strimwidth($sfField['Description'], 0, Field::DESCRIPTION_MAX_LENGTH);\n $field->label = $label;\n $field->type = $convertedType;\n $field->length = $sfField['Length'];\n $field->save();\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n private function convertFieldType(string $from, ?string $entityName = null): string\n {\n $converter = new FieldTypeConverter();\n\n return $converter->convert($from, $entityName);\n }\n\n /**\n * @inheritdoc\n */\n public function importPicklistValues(Field $field): array\n {\n $values = [];\n $fieldValues = [];\n\n try {\n if (FieldHelper::isCustomField($field)) {\n $query = '\n SELECT\n Id, Metadata, TableEnumOrId\n FROM\n CustomField\n WHERE\n DeveloperName = :fieldName\n AND\n TableEnumOrId = :fieldType\n AND\n NamespacePrefix = :namespacePrefix';\n\n // We need to constrain the field lookup to the object, in case it's used in multiple places.\n $objectType = \\in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true) ?\n 'activity' : $field->object_type;\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'fieldName' => substr($field->crm_provider_id, 0, -\\strlen('__c')),\n 'fieldType' => ucfirst($objectType),\n // This is used to ensure we only consider the field within the org, not installed packages.\n 'namespacePrefix' => 'null',\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n $valueSet = $sfField['Metadata']['valueSet'];\n\n if ($valueSet['valueSetName'] === null) {\n // Local picklist values can be obtained easily.\n $picklistValues = $valueSet['valueSetDefinition']['value'];\n } else {\n // But for some fields, we just get the Global Value Picklist pointer so need to do more work.\n $picklistValues = $this->importGlobalValuePicklistValues($valueSet['valueSetName']);\n }\n\n // Import all active values.\n foreach ($picklistValues as $i => $sfFieldValue) {\n // Setup default value.\n if ($sfFieldValue['default']) {\n $field->update(['default_value' => $sfFieldValue['valueName']]);\n }\n\n // This comes through as null if active (lol).\n if ($sfFieldValue['isActive'] !== false) {\n $values[] = [\n 'value' => $sfFieldValue['valueName'],\n 'label' => $sfFieldValue['valueName'],\n 'sequence' => $i,\n 'is_default' => $sfFieldValue['default'],\n ];\n }\n }\n } else {\n $objectFields = $this->getObjectFields($field->object_type);\n $fieldId = $field->crm_provider_id;\n\n // Only work with our field of interest.\n $objectField = array_filter($objectFields, function ($item) use ($fieldId) {\n return $item['name'] === $fieldId;\n });\n\n $objectField = array_shift($objectField);\n if (empty($objectField['picklistValues']) === false) {\n foreach ($objectField['picklistValues'] as $i => $sfFieldValue) {\n // Skip inactive values.\n if ($sfFieldValue['active'] === false) {\n continue;\n }\n\n // Setup default value.\n if ($sfFieldValue['defaultValue']) {\n $field->update(['default_value' => $sfFieldValue['value']]);\n }\n\n $values[] = [\n 'value' => $sfFieldValue['value'],\n 'label' => $sfFieldValue['label'],\n 'sequence' => $i,\n 'is_default' => $sfFieldValue['defaultValue'],\n ];\n }\n }\n }\n\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n $value['value'] = substr($value['value'] ?? '', 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n if (($key = array_search($value['value'], $fieldsToPurge, true)) !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n // Get IDs of the values to be deleted\n $valuesToDelete = $field->values()->whereIn('value', $fieldsToPurge);\n $valuesToDeleteIds = $valuesToDelete->pluck('id');\n if (! $valuesToDeleteIds->isEmpty()) {\n $recordTypeFieldValuesRepository = app(RecordTypeFieldValuesRepository::class);\n $recordTypeFieldValuesRepository->deleteForCrmFieldValueIds($valuesToDeleteIds->toArray());\n\n // Now safely delete from crm_field_values\n $valuesToDelete->delete();\n }\n\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n return $fieldValues;\n }\n\n /**\n * Gets values from Global Value Picklists.\n */\n private function importGlobalValuePicklistValues(string $picklistName): array\n {\n $query = '\n SELECT\n Metadata\n FROM\n GlobalValueSet\n WHERE\n DeveloperName = :picklistName\n LIMIT 1';\n\n try {\n $sfValues = $this->queryHandler->metadata($query, [\n 'picklistName' => $picklistName,\n ]);\n\n // There is always 1 result at this point.\n $sfValue = $sfValues->current();\n\n return $sfValue['Metadata']['customValue'];\n } catch (NoResultsException $noResultsException) {\n // Nothing returned.\n\n return [];\n }\n }\n\n /**\n * @inheritdoc\n */\n public function syncProfileRecordTypes(): void\n {\n $objectTypes = [\n 'lead',\n 'account',\n 'contact',\n 'opportunity',\n 'task',\n 'event',\n ];\n\n foreach ($objectTypes as $objectType) {\n try {\n $crmRecordTypes = $this->getRecordTypes(ucfirst($objectType));\n\n foreach ($crmRecordTypes as $crmRecordType) {\n // If the record type is default and not the Master type, set this.\n if ($crmRecordType['default'] && $crmRecordType['recordTypeId'] !== '012000000000000AAA') {\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $crmRecordType['recordTypeId'])\n ->first();\n\n if ($recordType) {\n $this->profile->{$objectType . '_record_type_id'} = $recordType->id;\n }\n }\n }\n } catch (HttpNotFoundException $exception) {\n Log::error('No access to ' . $objectType . ' object, skipping...');\n\n // XXX: should we log this fact somewhere?\n continue;\n }\n }\n\n if ($this->profile->isDirty()) {\n $this->profile->save();\n }\n }\n\n /**\n * Gets business processes.\n */\n public function importBusinessProcesses(): void\n {\n $query = '\n SELECT\n Id, IsActive, Name, TableEnumOrId\n FROM\n BusinessProcess\n WHERE\n TableEnumOrId IN (\\'Lead\\',\\'Opportunity\\')';\n\n try {\n $sfProcesses = $this->queryHandler->query($query);\n\n // Upsert all processes for the team.\n foreach ($sfProcesses as $sfProcess) {\n /** @var BusinessProcess $businessProcess */\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $sfProcess['Id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => $sfProcess['Name'],\n 'type' => $sfProcess['TableEnumOrId'] === 'Lead' ? 'lead' : 'opportunity',\n 'is_selectable' => $sfProcess['IsActive'],\n ]);\n\n $this->importBusinessProcessStages($businessProcess);\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n /**\n * Gets business process stages.\n */\n private function importBusinessProcessStages(BusinessProcess $businessProcess): void\n {\n $query = '\n SELECT\n Metadata\n FROM\n BusinessProcess\n WHERE\n Id = :processId';\n\n try {\n $stages = [];\n $sfProcessStages = $this->queryHandler->metadata($query, [\n 'processId' => $businessProcess->crm_provider_id,\n ]);\n\n // There is always 1 result at this point.\n $sfProcessStage = $sfProcessStages->current();\n\n // Upsert all processes for the team.\n foreach ($sfProcessStage['Metadata']['values'] as $sfProcessStage) {\n $sanitizedName = urldecode($sfProcessStage['valueName']); // Must decode: \"%2C\" becomes \",\" etc.\n\n $stage = $businessProcess->crm->stages()\n // This MUST match on label because this API doesn't use API Name.\n ->where('label', $sanitizedName)\n ->where('type', $businessProcess->type)\n ->where('is_selectable', 1)\n ->first();\n\n if ($stage) {\n $stages[] = $stage->id;\n }\n }\n\n $businessProcess->stages()->sync($stages);\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n /**\n * Gets record types.\n */\n public function importRecordTypes(): void\n {\n $query = '\n SELECT\n Id, IsActive, Name, BusinessProcessId, SobjectType\n FROM\n RecordType';\n\n try {\n $sfRecordTypes = $this->queryHandler->query($query);\n\n // Upsert all record types for the process.\n foreach ($sfRecordTypes as $sfRecordType) {\n $businessProcess = null;\n if ($sfRecordType['BusinessProcessId']) {\n $businessProcess = $this->config->businessProcesses()\n ->where('crm_provider_id', $sfRecordType['BusinessProcessId'])\n ->first();\n }\n\n /** @var RecordType $recordType */\n $recordType = $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $sfRecordType['Id'],\n ], [\n 'team_id' => $this->team->id,\n 'type' => mb_strtolower($sfRecordType['SobjectType']),\n 'name' => $sfRecordType['Name'],\n 'is_selectable' => $sfRecordType['IsActive'],\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n $this->importRecordTypeFieldValues($recordType);\n }\n } catch (NoResultsException $noResultsException) {\n // Do nothing.\n }\n }\n\n /**\n * Import record type - field value mappings. This only works for standard fields.\n */\n private function importRecordTypeFieldValues(RecordType $recordType): void\n {\n try {\n $query = '\n SELECT\n Metadata\n FROM\n RecordType\n WHERE\n Id = :recordTypeId';\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'recordTypeId' => $recordType->crm_provider_id,\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n // Sync field metadata.\n $picklists = $sfField['Metadata']['picklistValues'];\n\n foreach ($picklists as $picklist) {\n $field = $this->config->fields()->where([\n 'type' => Field::TYPE_PICKLIST,\n 'object_type' => $recordType->type,\n 'crm_provider_id' => $picklist['picklist'],\n ])->first();\n\n if ($field) {\n $fieldValues = [];\n\n foreach ($picklist['values'] as $value) {\n // Must decode: \"%2C\" becomes \",\" etc.\n $fieldValue = $field->values()\n ->where('value', urldecode($value['valueName']))\n ->first();\n\n if ($fieldValue) {\n $fieldValues[] = $fieldValue->id;\n }\n }\n\n $recordType->fieldValues()->sync($fieldValues);\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n /**\n * @inheritdoc\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $params = [];\n $missingStage = null;\n if ($types === null) {\n $types = [Stage::TYPE_LEAD, Stage::TYPE_OPPORTUNITY];\n }\n\n foreach ($types as $type) {\n if ($type === Stage::TYPE_LEAD) {\n $query = '\n SELECT\n Id, ApiName, MasterLabel, SortOrder\n FROM\n LeadStatus';\n } else {\n $query = '\n SELECT\n Id, ApiName, MasterLabel, IsActive, SortOrder, DefaultProbability\n FROM\n OpportunityStage';\n }\n\n if ($missingStageName) {\n $escapedStageName = ValueNormalizer::replaceQueryWithStringLiterals($missingStageName);\n\n $query .= ' WHERE ApiName = :stageName';\n\n $params = [\n 'stageName' => $escapedStageName,\n ];\n }\n\n try {\n $sfStages = $this->queryHandler->query($query, $params);\n } catch (NoResultsException $exception) {\n $sfStages = [];\n }\n\n $missingStage = null;\n\n // Upsert all stages for the team.\n foreach ($sfStages as $sfStage) {\n $selectable = true;\n if (array_key_exists('IsActive', $sfStage)) {\n $selectable = $sfStage['IsActive'];\n }\n\n $this->restoreAnyTrashedEntity($this->config->stages(), $sfStage['Id']);\n\n $stage = $this->config->stages()->updateOrCreate([\n 'crm_provider_id' => $sfStage['Id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($sfStage['ApiName'], 0, 50),\n 'label' => mb_strimwidth($sfStage['MasterLabel'], 0, 191),\n 'type' => $type,\n 'sequence' => $sfStage['SortOrder'] ?? 0,\n 'is_selectable' => $selectable,\n 'probability' => $sfStage['DefaultProbability'] ?? null,\n ]);\n\n if ($missingStageName && $missingStageName === $sfStage['ApiName']) {\n $missingStage = $stage;\n }\n }\n\n if ($missingStageName && $missingStage === null) {\n // If they requested a stage that still doesn't exist, it must be inactive so lazy create it.\n $missingStage = $this->config->stages()->create([\n 'crm_provider_id' => Uuid::uuid4(),\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($missingStageName, 0, 50),\n 'label' => mb_strimwidth($missingStageName, 0, 191),\n 'type' => $type,\n 'sequence' => 0,\n 'is_selectable' => 0,\n ]);\n }\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int\n {\n $syncCount = 0;\n $fields = $this->getAllFieldsAsArray('lead');\n if (\\in_array('Id', $fields, true) === false) {\n return $syncCount;\n }\n\n $query = '\n SELECT ' . rtrim(implode(',', $fields), ',') . '\n FROM Lead\n WHERE LastModifiedDate > :since\n ORDER BY LastModifiedDate ASC';\n\n try {\n $sfLeads = $this->queryHandler->query($query, [\n 'since' => $since->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($sfLeads as $sfLead) {\n // Only sync if previously imported.\n if ($this->hasLead($sfLead['Id'])) {\n $this->importLead($sfLead);\n $syncCount++;\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::LEAD);\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLead(string $crmId): ?Lead\n {\n $fields = $this->getAllFieldsAsArray('lead');\n\n $sfLead = $this->getRecord('Lead', $crmId, $fields);\n\n return $this->importLead($sfLead);\n }\n\n private function importLead($crmData): ?Lead\n {\n /** @var ?Stage $stage */\n $stage = null;\n if (isset($crmData['Status'])) {\n // Get the current stage.\n $stage = $this->config\n ->stages()\n ->where('name', $crmData['Status'])\n ->where('type', Stage::TYPE_LEAD)\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_LEAD], $crmData['Status']);\n }\n }\n\n // If we have no way of importing this, just return null :(\n if ($stage === null) {\n return null;\n }\n\n $countryCode = $crmData['CountryCode'] ?? null;\n\n // Salesforce allows custom \"countries\" to be created. Disregard these.\n if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {\n $countryCode = null;\n }\n\n // If we have no country code, try to parse it from the country name.\n if ($countryCode === null && empty($crmData['Country']) !== false) {\n $countryCode = $this->convertCountryNameToCode($crmData['Country']);\n }\n\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($crmData['Phone'] ?? '', 0, 25);\n $parsedNumber = parsePhoneNumber($countryCode, $number);\n\n $mobilePhone = null;\n if (empty($crmData['MobilePhone']) === false) {\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($crmData['MobilePhone'], 0, 25);\n $mobilePhone = phone_e164($countryCode, $number);\n }\n\n $convertedDate = null;\n $convertedAccount = null;\n $convertedOpportunity = null;\n $convertedContact = null;\n\n if ($crmData['IsConverted'] == 'true') {\n $convertedDate = $crmData['ConvertedDate'];\n\n if (empty($crmData['ConvertedAccountId']) === false) {\n $convertedAccount = $this->config\n ->accounts()\n ->where('crm_provider_id', $crmData['ConvertedAccountId'])\n ->first();\n\n if ($convertedAccount === null) {\n try {\n $convertedAccount = $this->syncAccount($crmData['ConvertedAccountId']);\n } catch (HttpNotFoundException $exception) {\n // Probably the user has no permissions to access the converted data.\n }\n }\n }\n\n if (empty($crmData['ConvertedOpportunityId']) === false) {\n $convertedOpportunity = $this->config\n ->opportunities()\n ->where('crm_provider_id', $crmData['ConvertedOpportunityId'])\n ->first();\n\n if ($convertedOpportunity === null) {\n try {\n $convertedOpportunity = $this->syncOpportunity($crmData['ConvertedOpportunityId']);\n } catch (HttpNotFoundException $exception) {\n // Probably the user has no permissions to access the converted data.\n }\n }\n }\n\n if (empty($crmData['ConvertedContactId']) === false) {\n $convertedContact = $this->team\n ->crm\n ->contacts()\n ->where('crm_provider_id', $crmData['ConvertedContactId'])\n ->first();\n\n if ($convertedContact === null) {\n try {\n $convertedContact = $this->syncContact($crmData['ConvertedContactId']);\n } catch (HttpNotFoundException $exception) {\n // Probably the user has no permissions to access the converted data.\n }\n }\n }\n }\n\n if (empty($crmData['Company'])) {\n $company = 'Unknown';\n } else {\n $company = mb_strimwidth($crmData['Company'], 0, 191);\n }\n\n $domain = null;\n if (empty($crmData['Website']) === false) {\n $domain = mb_strimwidth($crmData['Website'], 0, 191);\n $domain = StringUtil::resolveDomain($domain);\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $data = [\n 'team_id' => $this->team->id,\n 'user_id' => $profile?->user_id,\n 'owner_id' => $crmData['OwnerId'] ?? '',\n 'company' => $company,\n 'domain' => $domain,\n 'name' => $crmData['Name'] ? mb_strimwidth($crmData['Name'], 0, 191) : '',\n 'title' => $crmData['Title'] ? mb_strimwidth($crmData['Title'], 0, 128) : null,\n 'email' => $crmData['Email'] ? mb_strimwidth($crmData['Email'], 0, 80) : null,\n 'phone' => $parsedNumber['phone'],\n 'ext' => $parsedNumber['ext'] ?? null,\n 'mobile_phone' => $mobilePhone,\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['Id'],\n modelType: Lead::class,\n fileName: $crmData['Id'],\n avatarText: $crmData['Name']\n ),\n 'stage_id' => $stage->id,\n 'record_type_id' => null,\n 'converted_at' => $convertedDate,\n 'converted_account_id' => $convertedAccount->id ?? null,\n 'converted_opportunity_id' => $convertedOpportunity->id ?? null,\n 'converted_contact_id' => $convertedContact->id ?? null,\n 'country_code' => $countryCode,\n 'remotely_created_at' => $createdDate,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->leads(), $crmData['Id']);\n\n /** @var Lead $lead */\n $lead = $this->config->leads()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n if ($lead->wasChanged('converted_at') && $lead->getConvertedAt() !== null) {\n $this->eventDispatcher->dispatch(new LeadConverted($lead));\n }\n\n $this->handleObjectDeletion($lead, $crmData);\n\n return $lead;\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccounts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n $fields = $this->getAllFieldsAsArray('account');\n\n if (\\in_array('Id', $fields, true) === false) {\n return $syncCount;\n }\n\n $query = '\n SELECT ' . rtrim(implode(',', $fields), ',') . '\n FROM Account\n WHERE LastModifiedDate > :since\n ORDER BY LastModifiedDate ASC';\n\n try {\n $sfAccounts = $this->queryHandler->query($query, [\n 'since' => $since->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($sfAccounts as $sfAccount) {\n // Only sync if previously imported.\n if ($this->hasAccount($sfAccount['Id'])) {\n $this->importAccount($sfAccount);\n $syncCount++;\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::ACCOUNT);\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccount(string $crmId): ?Account\n {\n $fields = $this->getAllFieldsAsArray('account');\n if (! in_array('Id', $fields, true)) {\n $this->logger->info('[Salesforce] Sync account cancelled. Fields are not available.', [\n 'crmId' => $crmId,\n 'userId' => $this->profile->getUserId(),\n ]);\n\n return null;\n }\n\n $sfAccount = $this->getRecord('Account', $crmId, $fields);\n\n return $this->importAccount($sfAccount);\n }\n\n private function importAccount($crmData): Account\n {\n $countryCode = $crmData['BillingCountryCode'] ?? $crmData['ShippingCountryCode'] ?? null;\n\n // Salesforce allows custom \"countries\" to be created. Disregard these.\n if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {\n $countryCode = null;\n }\n\n // If we have no country code, try to parse it from the country names.\n if ($countryCode === null && empty($crmData['BillingCountry']) === false) {\n $countryCode = $this->convertCountryNameToCode($crmData['BillingCountry']);\n }\n\n if ($countryCode === null && empty($crmData['ShippingCountry']) === false) {\n $countryCode = $this->convertCountryNameToCode($crmData['ShippingCountry']);\n }\n\n if (empty($crmData['Phone']) === false) {\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($crmData['Phone'], 0, 25);\n $parsedNumber = parsePhoneNumber($countryCode, $number);\n } else {\n $parsedNumber = [];\n }\n\n $industry = null;\n if (empty($crmData['Industry']) === false) {\n $industry = mb_strimwidth($crmData['Industry'], 0, 40);\n }\n\n $domain = null;\n if (empty($crmData['Website']) === false) {\n $domain = mb_strimwidth($crmData['Website'], 0, 191);\n $domain = StringUtil::resolveDomain($domain);\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $data = [\n 'team_id' => $this->team->id,\n 'user_id' => $profile?->user_id,\n 'owner_id' => $crmData['OwnerId'],\n 'name' => mb_strimwidth($crmData['Name'], 0, 191),\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['Id'],\n modelType: Account::class,\n fileName: $crmData['Id'],\n avatarText: $crmData['Name']\n ),\n 'industry' => $industry,\n 'domain' => $domain,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'ext' => $parsedNumber['ext'] ?? null,\n 'country_code' => $countryCode,\n 'remotely_created_at' => $createdDate,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->accounts(), $crmData['Id']);\n\n /** @var Account $account */\n $account = $this->config->accounts()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n $this->handleObjectDeletion($account, $crmData);\n\n return $account;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOpportunities(array $parameters, ?string $strategy = null): int\n {\n $strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);\n\n $syncCount = 0;\n $logParams = $parameters;\n $parameters['profile'] = $this->profile;\n $logParams['user'] = $this->profile->getUserId();\n\n if (count($strategies) > 1) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Multiple sync strategies used', [\n 'teamId' => $this->team->getUuid(),\n 'params' => $logParams,\n 'strategies_count' => count($strategies),\n ]);\n }\n\n foreach ($strategies as $syncStrategy) {\n $name = $syncStrategy->getStrategyName();\n\n try {\n $sfOpportunities = $syncStrategy->fetchOpportunities($parameters);\n $totalRecords = $sfOpportunities->count();\n\n foreach ($sfOpportunities as $sfOpportunity) {\n $this->importOpportunity($sfOpportunity);\n $syncCount++;\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n $this->logger->warning('[' . $this->getDisplayName() . '] No opportunities found', [\n 'teamId' => $this->team->getUuid(),\n 'name' => $name,\n 'params' => $logParams,\n 'reason' => $noResultsException->getMessage(),\n ]);\n } catch (CrmException $crmException) {\n // Nothing to sync.\n $this->logger->warning('[' . $this->getDisplayName() . '] Opportunity sync failed', [\n 'teamId' => $this->team->getUuid(),\n 'name' => $name,\n 'params' => $logParams,\n 'reason' => $crmException->getMessage(),\n ]);\n }\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::OPPORTUNITY, ['params' => $logParams]);\n\n // debug to see how if count of opportunities reaches 1000\n if ($syncCount >= 1000) {\n $this->logger->info(\n '[' . $this->getDisplayName() . '] Sync Opportunities - count warning',\n [\n 'team_id' => $this->team->getId(),\n 'params' => $logParams,\n 'count' => $syncCount,\n 'strategies_count' => count($strategies),\n 'total_records' => $totalRecords ?? null,\n ]\n );\n }\n\n return $syncCount;\n }\n\n\n /**\n * @inheritdoc\n */\n public function syncOpportunity(string $crmId): ?Opportunity\n {\n $strategy = $this->opportunitySyncStrategyResolver->resolve(\n $this->config,\n OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY\n );\n\n $parameters = [\n 'profile' => $this->profile,\n 'crm_id' => $crmId,\n ];\n\n try {\n $sfOpportunity = $strategy->fetchOpportunities($parameters);\n } catch (HttpNotFoundException $e) {\n $this->logger->info('[' . $this->getDisplayName() . '] Opportunity not found', [\n 'teamId' => $this->team->id_string,\n 'crmId' => $crmId,\n ]);\n\n return null;\n } catch (CrmException $crmException) {\n $this->logger->info('[' . $this->getDisplayName() . '] Opportunity sync failed', [\n 'teamId' => $this->team->id_string,\n 'crmId' => $crmId,\n 'exception' => $crmException->getMessage(),\n ]);\n\n return null;\n }\n\n if ($sfOpportunity instanceof ArrayIterator) {\n return $this->importOpportunity($sfOpportunity->getItems());\n }\n\n return $this->importOpportunity($sfOpportunity);\n }\n\n /**\n * @throws HttpNotFoundException\n */\n private function importOpportunity($crmData): ?Opportunity\n {\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $account = null;\n if (empty($crmData['AccountId']) === false) {\n /** @var ?Account $account */\n $account = $this->config->accounts()\n ->where('crm_provider_id', (string) $crmData['AccountId'])\n ->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmData['AccountId']);\n }\n }\n\n $userId = $profile?->getUserId() ?? $account?->getUserId();\n if ($userId === null) {\n $this->logger->error('[Salesforce] | Skip import, no user_id found', [\n 'id' => $crmData['Id'],\n ]);\n\n return null;\n }\n\n /** @var ?Stage $stage */\n $stage = null;\n if (isset($crmData['StageName'])) {\n $stage = $this->config\n ->stages()\n ->where('name', $crmData['StageName'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->orderBy('is_selectable', 'DESC')\n ->orderBy('id')\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $crmData['StageName']);\n }\n }\n\n $recordType = null;\n if (empty($crmData['RecordTypeId']) === false) {\n /** @var ?RecordType $recordType */\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $crmData['RecordTypeId'])\n ->first();\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $closeDate = null;\n if (empty($crmData['CloseDate']) === false) {\n $closeDate = Carbon::parse($crmData['CloseDate'])->format('Y-m-d');\n }\n\n $valueFieldName = 'Amount';\n if ($this->config->opportunity_value_field_id) {\n $valueFieldName = $this->config->opportunityValueField->crm_provider_id;\n }\n\n $data = [\n 'team_id' => $this->team->id,\n 'account_id' => $account->id ?? null,\n 'user_id' => $userId,\n 'owner_id' => $crmData['OwnerId'] ?? null,\n 'name' => mb_strimwidth($crmData['Name'] ?? '', 0, 128),\n 'value' => $crmData[$valueFieldName],\n 'currency_code' => CurrencyFormatter::formatCode($crmData['CurrencyIsoCode'] ?? null),\n 'close_date' => $closeDate,\n 'is_closed' => $crmData['IsClosed'],\n 'is_won' => $crmData['IsWon'],\n 'stage_id' => $stage?->id ?? null,\n 'record_type_id' => $recordType->id ?? null,\n 'remotely_created_at' => $createdDate,\n 'probability' => $crmData['Probability'] ?? null,\n 'forecast_category' => $crmData['ForecastCategoryName'] ?? null,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->opportunities(), $crmData['Id']);\n\n // Do not allow locked DB tables & other errors\n // to interrupt the process of reverting the trashed opportunities\n try {\n /** @var Opportunity $opportunity */\n $opportunity = $this->config->opportunities()\n ->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n // import external fields into crm_field_data if present\n $crmFields = $this->getOpportunitySyncableFields();\n\n $this->importOpportunityCrmFieldData($crmData, $crmFields, $opportunity->id);\n\n $this->handleObjectDeletion($opportunity, $crmData);\n\n if ($opportunity->wasRecentlyCreated) {\n MatchActivitiesToNewOpportunity::dispatch($opportunity->getId());\n }\n\n return $opportunity;\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n $this->logger->error('[Salesforce] importOpportunity failure.', [\n 'crm_provider_id' => $crmData['Id'],\n 'team_id' => $this->team->id,\n 'exception' => $exception->getMessage(),\n ]);\n\n $this->handleEntityDeletionByProviderId($this->config->opportunities(), $crmData);\n }\n\n return null;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContacts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n $fields = $this->getAllFieldsAsArray('contact');\n if (\\in_array('Id', $fields, true) === false) {\n return $syncCount;\n }\n\n $query = '\n SELECT ' . rtrim(implode(',', $fields), ',') . '\n FROM Contact\n WHERE LastModifiedDate > :since\n ORDER BY LastModifiedDate ASC';\n\n try {\n $sfContacts = $this->queryHandler->query($query, [\n 'since' => $since->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($sfContacts as $sfContact) {\n // Only sync if previously imported.\n if ($this->hasContact($sfContact['Id'])) {\n $this->importContact($sfContact);\n $syncCount++;\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::CONTACT);\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContact(string $crmId): ?Contact\n {\n $fields = $this->getAllFieldsAsArray('contact');\n if (! in_array('Id', $fields, true)) {\n $this->logger->info('[Salesforce] Sync contact cancelled. Fields are not available.', [\n 'crmId' => $crmId,\n 'userId' => $this->profile->getUserId(),\n ]);\n\n return null;\n }\n\n $sfContact = $this->getRecord('Contact', $crmId, $fields);\n\n return $this->importContact($sfContact);\n }\n\n private function importContact($crmData): Contact\n {\n $account = null;\n // Contacts may not have accounts...\n if (isset($crmData['AccountId'])) {\n $account = $this->config->accounts()\n ->where('crm_provider_id', (string) $crmData['AccountId'])\n ->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmData['AccountId']);\n }\n }\n\n $countryCode = $crmData['MailingCountryCode'] ?? null;\n\n // Salesforce allows custom \"countries\" to be created. Disregard these.\n if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {\n $countryCode = null;\n }\n\n // If we have no country code, try to parse it from the country name.\n if ($countryCode === null && empty($crmData['MailingCountry']) === false) {\n $countryCode = $this->convertCountryNameToCode($crmData['MailingCountry']);\n\n if ($countryCode === null && $account) {\n $countryCode = $account->country_code;\n }\n }\n\n $ext = null;\n $parsedNumber = [];\n if (empty($crmData['Phone']) === false) {\n $number = Str::limit($crmData['Phone'], 25, '');\n $parsedNumber = parsePhoneNumber($countryCode, $number);\n\n if (empty($parsedNumber['ext']) === false) {\n $ext = Str::limit($parsedNumber['ext'], 10, '');\n }\n }\n\n $mobileNumber = null;\n if (empty($crmData['MobilePhone']) === false) {\n $mobileNumber = Str::limit(phone_e164($countryCode, $crmData['MobilePhone']), 25, '');\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $data = [\n 'team_id' => $this->team->id,\n 'account_id' => $account->id ?? null,\n 'user_id' => $profile?->user_id,\n 'owner_id' => $crmData['OwnerId'] ?? null,\n 'name' => ($crmData['Name'] ?? null) !== null ? mb_strimwidth($crmData['Name'], 0, 100) : '',\n 'title' => ($crmData['Title'] ?? null) !== null ? mb_strimwidth($crmData['Title'], 0, 128) : null,\n 'email' => ($crmData['Email'] ?? null) !== null ? mb_strimwidth($crmData['Email'], 0, 191) : null,\n 'country_code' => $countryCode,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'ext' => $ext,\n 'mobile_phone' => $mobileNumber,\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['Id'],\n modelType: Contact::class,\n fileName: $crmData['Id'],\n avatarText: $crmData['Name']\n ),\n 'remotely_created_at' => $createdDate,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->contacts(), $crmData['Id']);\n\n /** @var Contact $contact */\n $contact = $this->config->contacts()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n $this->handleObjectDeletion($contact, $crmData);\n\n return $contact;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n $fields = [\n 'InstanceName',\n 'OrganizationType',\n 'IsSandbox',\n ];\n\n $orgValues = $this->getRecord('Organization', $this->config->crm_provider_id, $fields);\n\n $edition = null;\n switch ($orgValues['OrganizationType']) {\n case 'Developer Edition':\n $edition = Configuration::EDITION_DEVELOPER;\n\n break;\n\n case 'Professional Edition':\n $edition = Configuration::EDITION_PROFESSIONAL;\n\n break;\n\n case 'Enterprise Edition':\n $edition = Configuration::EDITION_ENTERPRISE;\n\n break;\n }\n\n $this->config->edition = $edition;\n $this->config->instance = $orgValues['InstanceName'];\n\n // XXX: How can this state be possible?\n if ($this->config->version === null) {\n $this->config->version = Client::MIN_API_VERSION;\n }\n\n $installedVersion = $this->getInstalledAppVersion();\n if ($installedVersion !== null) {\n $installedVersion = (string) $this->getInstalledAppVersion();\n }\n\n $this->config->installed_app_version = $installedVersion;\n\n $this->config->save();\n }\n\n public function getInstalledAppVersion(): ?string\n {\n try {\n $query = '\n SELECT\n SubscriberPackageVersion.MajorVersion,\n SubscriberPackageVersion.MinorVersion,\n SubscriberPackageVersion.PatchVersion,\n SubscriberPackageVersion.BuildNumber\n FROM\n InstalledSubscriberPackage\n WHERE\n SubscriberPackageId = :packageId\n ';\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'packageId' => self::INSTALLED_PACKAGE_ID,\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n // Grab version number.\n $version = $sfField['SubscriberPackageVersion']['MajorVersion'] .\n $sfField['SubscriberPackageVersion']['MinorVersion'] .\n $sfField['SubscriberPackageVersion']['PatchVersion'] .\n $sfField['SubscriberPackageVersion']['BuildNumber'];\n } catch (\\Exception) {\n $version = null;\n }\n\n return $version;\n }\n\n /**\n * Store transcripts as note.\n *\n * @throws \\Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // For SF we also check if Log Notes is enabled.\n if ($this->profile->log_notes === Profile::LOG_NOTE_NONE) {\n return;\n }\n\n if ($activity->opportunity_id && $activity->prospect === null) {\n return;\n }\n\n try {\n $transcriptionData = $this->generateTranscription($activity);\n\n $noteMaxLength = $this->profile->log_notes === Profile::LOG_NOTE_ENHANCED\n ? self::ENHANCED_NOTE_MAX_LENGTH\n : self::CLASSIC_NOTE_MAX_LENGTH;\n\n $title = 'Transcript for ';\n $title .= $activity->title ?? $activity->activity_title;\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $body = mb_strimwidth($transcriptionData, 0, $noteMaxLength);\n\n if ($activity->opportunity_id) {\n $objectId = $activity->opportunity->crm_provider_id;\n } else {\n $objectId = $activity->prospect->crm_provider_id;\n }\n\n $noteId = $this->saveNote($title, $body, $objectId);\n\n // Store crm logged id in transcription.\n $transcription = $activity->getTranscription();\n $transcription->crm_activity_id = $noteId;\n $transcription->save();\n } catch (\\Exception $e) {\n \\Sentry::captureException($e);\n }\n }\n\n public function saveNote(string $title, string $body, string $objectId, ?NoteObject $noteObject = null): ?string\n {\n $noteId = null;\n\n try {\n if ($this->profile->log_notes === Profile::LOG_NOTE_ENHANCED) {\n $noteId = $this->buildEnhancedNote($title, $body, $objectId);\n } else {\n $noteId = $this->buildClassicNote($title, $body, $objectId);\n }\n } catch (HttpNotFoundException $exception) {\n // The profile not having access to create Enhanced Notes. Set their preference to Classic.\n if ($this->profile->log_notes === Profile::LOG_NOTE_ENHANCED) {\n $this->profile->update([\n 'log_notes' => Profile::LOG_NOTE_CLASSIC,\n ]);\n }\n }\n\n return $noteId;\n }\n\n /**\n * This is using the \"Enhanced\" Notes feature, NOT the \"Notes & Attachments\" feature being deprecated.\n *\n * @url https://salesforce.stackexchange.com/questions/104408/how-can-i-create-an-account-note-or-contact-note-via-api-that-is-visible-in-sale\n */\n private function buildEnhancedNote(string $title, string $body, string $objectId): string\n {\n // Decode stored entities, escape HTML (without quoting), then convert line breaks for Salesforce formatting\n $decodedBody = html_entity_decode($body, ENT_QUOTES | ENT_HTML5);\n $sanitizedBody = htmlspecialchars($decodedBody, ENT_NOQUOTES, 'UTF-8', false);\n $content = nl2br($sanitizedBody, false);\n $note = [\n 'OwnerId' => $this->profile->crm_provider_id,\n 'Title' => $title,\n 'Content' => base64_encode($content),\n ];\n\n $noteId = $this->createRecord('ContentNote', $note);\n\n $link = [\n 'ContentDocumentId' => $noteId,\n 'LinkedEntityId' => $objectId,\n 'ShareType' => 'I',\n ];\n\n $this->createRecord('ContentDocumentLink', $link);\n\n return $noteId;\n }\n\n private function buildClassicNote(string $title, string $body, string $objectId): string\n {\n if (in_array($this->parseObjectType($objectId), [Field::OBJECT_TASK, Field::OBJECT_EVENT])) {\n $this->logger->info('[Salesforce] Summary not sent', [\n 'profile_id' => $this->profile->id,\n 'objectId' => $objectId,\n 'reason' => 'Classical Note does not support Task/Event relation',\n ]);\n\n return '';\n }\n\n $titleTrimmed = null;\n\n if (mb_strlen($title) > 80) {\n $titleTrimmed = substr($title, 0, 77) . '...';\n }\n $payload = [\n 'OwnerId' => $this->profile->crm_provider_id,\n 'IsPrivate' => false,\n 'Title' => $titleTrimmed ?? $title,\n 'Body' => $titleTrimmed ? $title . PHP_EOL . $body : $body,\n 'ParentId' => $objectId,\n ];\n\n return $this->createRecord('Note', $payload);\n }\n\n /**\n * @inheritdoc\n */\n public function find(string $name, array $scopes): array\n {\n if ($this->profile === null) {\n return [];\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $limitValues = ['limit' => $this->limit, 'offset' => $this->offset];\n $sosl = $queryBuilder->buildFindQuery($name, $scopes, $limitValues);\n\n $this->logger->info('[Salesforce] Find prospects', [\n 'profile_id' => $this->profile->id,\n 'sosl_query' => $sosl,\n 'search_string' => $name,\n 'scopes' => $scopes,\n ]);\n\n $data = Cache::remember($this->profile->id . $sosl, self::CACHE_TTL, function () use ($sosl) {\n $data = [];\n\n try {\n // Hit remote API.\n $objects = $this->queryHandler->search($sosl);\n\n // Build mapped list.\n foreach ($objects as $object) {\n $type = strtolower($object['attributes']['type']);\n\n $record = [\n 'crmId' => $object['Id'],\n 'name' => $object['Name'],\n 'prospectType' => $type,\n 'phoneNumbers' => [],\n 'crmUrl' => $this->generateProviderUrl($object['Id'], $type),\n ];\n\n switch ($type) {\n case 'lead':\n if (empty($object['Company']) === false) {\n $record['organization'] = $object['Company'];\n }\n\n if (empty($object['Title']) === false) {\n $record['title'] = $object['Title'];\n }\n\n $stage = $this->config->stages()\n ->where('type', Stage::TYPE_LEAD)\n ->where('name', $object['Status'])\n ->first();\n\n // Lazy create the stage.\n if ($stage === null) {\n $stage = $this->importStages([Stage::TYPE_LEAD], $object['Status']);\n }\n\n if ($stage) {\n $record += [\n 'stage' => [\n 'id' => $stage->id_string,\n 'name' => $stage->name,\n ],\n ];\n }\n\n if (empty($object['RecordTypeId']) === false) {\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $object['RecordTypeId'])\n ->first();\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n }\n\n break;\n\n case 'account':\n if (empty($object['Industry']) === false) {\n $record['industry'] = $object['Industry'];\n $record['detailsLine'] = $object['Industry'];\n }\n if (! empty($object['PersonEmail'])) {\n $record['detailsLine'] = $object['PersonEmail'];\n }\n\n break;\n\n case 'contact':\n // For contacts, we should try and fetch their account name too.\n if ($object['AccountId']) {\n // Cheaper to get this locally.\n $account = $this->config->accounts()\n ->where('crm_provider_id', $object['AccountId'])\n ->first(['name']);\n\n if ($account) {\n $record['organization'] = $account->name;\n }\n }\n\n if (! empty($object['IsPersonAccount']) && $object['Email']) {\n $record['detailsLine'] = $object['Email'];\n } else {\n if (empty($object['Title']) === false) {\n $record['title'] = $object['Title'];\n }\n }\n\n break;\n }\n\n // Add phone numbers to record.\n if (empty($object['Phone']) === false && $object['Phone']) {\n $record['phoneNumbers'][] = [\n 'number' => $object['Phone'],\n 'nationalFormat' => phone_national($this->profile->user->country_code, $object['Phone']),\n 'type' => 'phone',\n ];\n }\n\n if (empty($object['MobilePhone']) === false && $object['MobilePhone']) {\n $record['phoneNumbers'][] = [\n 'number' => $object['MobilePhone'],\n 'nationalFormat' => phone_national(\n $this->profile->user->country_code,\n $object['MobilePhone']\n ),\n 'type' => 'mobile',\n ];\n }\n\n $data[] = $record;\n }\n } catch (NoResultsException $e) {\n $data = [];\n }\n\n return $data;\n });\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n\n if ($crmAccountId === null) {\n return $data;\n }\n\n if ($userId) {\n $profileRepository = app(ProfileRepository::class);\n $profile = $profileRepository->findProfileByUserId($this->config, $userId);\n\n $ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;\n }\n\n try {\n // Perhaps their profile has no opportunity permissions.\n if ($this->profile === null || $this->profile->opportunity_fields === null) {\n return $data;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $query = $queryBuilder->buildFindOpportunitiesQuery();\n\n $objects = $this->queryHandler->query($query, ['accountId' => $crmAccountId]);\n\n foreach ($objects as $object) {\n $record = [\n 'crmId' => $object['Id'],\n 'name' => $object['Name'],\n 'won' => $object['IsWon'],\n 'closed' => $object['IsClosed'],\n ];\n\n $valueFieldName = 'Amount';\n if ($this->config->opportunity_value_field_id) {\n $valueFieldName = $this->config->opportunityValueField->crm_provider_id;\n }\n\n if (empty($object[$valueFieldName]) === false) {\n $currency = $object['CurrencyIsoCode'] ?? $this->config->default_currency;\n $value = formatCurrency($object[$valueFieldName], $currency);\n\n $record += [\n 'value' => $value,\n ];\n }\n\n $stage = $this->config->stages()\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->where('name', $object['StageName'])\n ->first();\n\n // Lazy create the stage.\n if ($stage === null) {\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $object['StageName']);\n }\n\n $record += [\n 'stage' => [\n 'id' => $stage->id_string,\n 'name' => $stage->name,\n ],\n ];\n\n if (empty($object['RecordTypeId']) === false) {\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $object['RecordTypeId'])\n ->first();\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n }\n\n if ($ownerId && isset($object['OwnerId']) && $object['OwnerId'] === $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n } catch (NoResultsException $e) {\n return $data;\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n public function getContactRolesFromCrm(?Carbon $since = null): array\n {\n $roles = [];\n\n if ($this->profile === null) {\n return $roles;\n }\n\n $queryBuilder = app(QueryBuilder::class, ['profile' => $this->profile]);\n\n $query = $queryBuilder->buildGetContactRolesQuery($since);\n\n try {\n $objects = $this->queryHandler->query($query);\n\n foreach ($objects as $object) {\n $roles[] = [\n 'id' => $object['Id'],\n 'contactId' => $object['ContactId'],\n 'opportunityId' => $object['OpportunityId'],\n 'ownerId' => $object['Opportunity']['OwnerId'] ?? null,\n 'isPrimary' => $object['IsPrimary'],\n 'role' => $object['Role'],\n ];\n }\n } catch (NoResultsException) {\n // Just return an empty array.\n $this->logger->info('[Salesforce] No contact roles found', [\n 'since' => $since?->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n }\n\n return $roles;\n }\n\n public function syncContactRoles(Carbon $since): int\n {\n $contactRoleRepository = app(ContactRoleRepository::class);\n\n $crmContactRoles = $this->getContactRolesFromCrm(since: $since);\n $syncCount = 0;\n $contactRoles = [];\n\n foreach ($crmContactRoles as $crmContactRole) {\n $contactRoles[] = $this->importContactRole($crmContactRole);\n $syncCount++;\n }\n\n $contactRoleRepository->saveContactRoles($contactRoles);\n\n $this->syncRemotelyDeletedContactRoles();\n\n return $syncCount;\n }\n\n private function importContactRole(array $contactRole): array\n {\n $contact = $this->config->contacts()\n ->where('crm_provider_id', $contactRole['contactId'])\n ->first();\n\n if ($contact === null) {\n $contact = $this->syncContact($contactRole['contactId']);\n }\n\n $opportunity = $this->config->opportunities()\n ->where('crm_provider_id', $contactRole['opportunityId'])\n ->first();\n\n if ($opportunity === null) {\n $opportunity = $this->syncOpportunity($contactRole['opportunityId']);\n }\n\n $role = null;\n if (! empty($contactRole['role'])) {\n $role = mb_strimwidth($contactRole['role'], 0, 191);\n }\n\n return [\n 'crm_configuration_id' => $this->config->getId(),\n 'contact_id' => $contact->getId(),\n 'crm_provider_id' => $contactRole['id'],\n 'subject_type' => ContactRole::SUBJECT_TYPE_OPPORTUNITY,\n 'subject_id' => $opportunity->getId(),\n 'is_primary' => $contactRole['isPrimary'],\n 'role' => $role,\n ];\n }\n\n protected function syncRemotelyDeletedContactRoles(): bool\n {\n try {\n $deletedRemotely = $this->queryHandler->queryDeleted('OpportunityContactRole');\n } catch (NoResultsException $e) {\n return false;\n }\n\n $deletedOpportunities = $deletedRemotely->getResults();\n $deletedIds = array_column($deletedOpportunities, 'id');\n\n $contactRoleRepository = app(ContactRoleRepository::class);\n\n foreach (array_chunk($deletedIds, self::HARD_DELETE_CHUNK) as $chunk) {\n $contactRoleRepository->deleteContactRoles($chunk);\n\n $this->logger->info('[' . $this->getDisplayName() . '] Remotely deleted opportunities synced', [\n 'teamId' => $this->team->id_string,\n 'remotelyDeletedOpportunities' => $chunk,\n 'count' => count($chunk),\n ]);\n }\n\n return true;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = $objects = [];\n\n $hasWho = \\in_array($objectType, ['lead', 'contact']);\n $playbook = $this->getPlaybook($this->profile->user);\n $fields = array_merge(\n $this->profile->getFieldsAsArray(parent::OBJECT_TASK),\n $playbook && $playbook->activityField ? [$playbook->activityField->crm_provider_id] : []\n );\n\n // Query should default to any open call for that user.\n $query = '\n SELECT ' . implode(',', array_unique($fields)) . '\n FROM Task\n WHERE OwnerId = :ownerId\n AND IsArchived = false\n AND IsDeleted = false\n AND IsClosed = false\n AND (';\n\n if ($objectType === 'account') {\n // This covers tasks tied to a related contact or opportunity too.\n $query .= '\n AccountId = :accountId';\n }\n\n if ($hasWho) {\n $query .= '\n WhoId = :whoId';\n\n // If we are also going to check on a specific opportunity, set that up.\n if ($opportunityId) {\n $query .= ' OR WhatId = :whatId';\n }\n }\n\n $query .= ' ) ORDER BY LastModifiedDate DESC';\n\n try {\n $objects = $this->queryHandler->query($query, [\n 'ownerId' => $this->profile->crm_provider_id,\n 'whoId' => $objectId,\n 'whatId' => $opportunityId,\n 'accountId' => $objectId,\n ]);\n } catch (NoResultsException $e) {\n return $data;\n } finally {\n $this->logger->debug(sprintf('[Salesforce] Found %s tasks for query \"%s\"', count($objects), $query));\n }\n\n foreach ($objects as $object) {\n $dueDate = $object['ActivityDate'] ? Carbon::parse($object['ActivityDate'])->toIso8601String() : null;\n $data[] = [\n 'crmId' => $object['Id'],\n 'subject' => $object['Subject'],\n 'due' => $dueDate,\n 'type' => $object[$playbook->activityField->crm_provider_id],\n ];\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getEvents(string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = $objects = [];\n $user = $this->profile?->user;\n if ($this->profile === null || $user === null) {\n return $data;\n }\n\n $hasWho = \\in_array($objectType, ['lead', 'contact']);\n $playbook = $this->getPlaybook($user);\n $fields = array_merge(\n $this->profile->getFieldsAsArray(parent::OBJECT_EVENT),\n $playbook && $playbook->activityField ? [$playbook->activityField->crm_provider_id] : []\n );\n\n // Query should default to any event starting in the last week and ending up until today owned by the user.\n $query = '\n SELECT ' . implode(',', array_unique($fields)) . '\n FROM Event\n WHERE OwnerId = :ownerId\n AND IsArchived = false\n AND IsAllDayEvent = false\n AND StartDateTime >= LAST_N_DAYS:7\n AND EndDateTime <= TODAY\n AND (';\n\n if ($objectType === 'account') {\n // This covers events tied to a related contact or opportunity too.\n $query .= '\n AccountId = :accountId';\n }\n\n if ($hasWho) {\n $query .= '\n WhoId = :whoId';\n\n // If we are also going to check on a specific opportunity, set that up.\n if ($opportunityId) {\n $query .= ' OR WhatId = :whatId';\n }\n }\n\n $query .= ' ) ORDER BY LastModifiedDate DESC';\n\n try {\n $objects = $this->queryHandler->query($query, [\n 'ownerId' => $this->profile->crm_provider_id,\n 'whoId' => $objectId,\n 'whatId' => $opportunityId,\n 'accountId' => $objectId,\n ]);\n } catch (NoResultsException $e) {\n return $data;\n } finally {\n $this->logger->debug(sprintf('[Salesforce] Found %s tasks for query \"%s\"', count($objects), $query));\n }\n\n foreach ($objects as $object) {\n $dueDate = $object['StartDateTime'] ? Carbon::parse($object['StartDateTime'])->toIso8601String() : null;\n\n $data[] = [\n 'crmId' => $object['Id'],\n 'subject' => $object['Subject'],\n 'due' => $dueDate,\n 'type' => $object[$playbook->activityField->crm_provider_id],\n ];\n }\n\n return $data;\n }\n\n /**\n * Try to find CRM Objects using email address\n *\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchExactlyByEmail(string $email, ?int $userId = null): ?array\n {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $sosl = $queryBuilder->buildMatchByQuery($email, Field::TYPE_EMAIL);\n if ($sosl === null) {\n return null;\n }\n\n try {\n $objects = $this->queryHandler->search($sosl);\n $objects = $this->queryHandler->prioritiseResults(\n $objects,\n $email,\n QueryHandler::PRIORITISE_EMAIL\n );\n\n $data = $this->convertCrmData($objects, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (NoResultsException $e) {\n // Try the account next.\n if ($this->profile->account_fields === null) {\n return null;\n }\n }\n\n return null;\n }\n\n public function getDomain(string $email): ?string\n {\n // SF improved search - strip the domain extension, min domain name length 4\n return $this->getCompanyNameFromEmail(email: $email, minNameLength: 4);\n }\n\n /**\n * Try to find CRM objects using domain name of the email address\n *\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByDomain(string $domain, ?int $userId = null): ?array\n {\n $companyName = $domain;\n\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $sosl = $queryBuilder->buildMatchByDomainQuery($companyName);\n\n try {\n $objects = $this->queryHandler->search($sosl);\n\n $data = $this->convertCrmData($objects, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (NoResultsException) {\n return null;\n }\n }\n\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n // Don't bother looking up numbers that are masked.\n if (str_contains($phone, '**')) {\n return null;\n }\n\n if ($this->isPhoneNumberOfTeamMember($phone)) {\n return null;\n }\n\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $phoneNational = phone_national(null, $phone) ?? '';\n $possiblePhoneFormats = collect([\n preg_replace('/\\D/', '', ltrim($phone, '0+')),\n preg_replace('/\\D/', '', $phoneNational),\n formatDashPhoneNumber($phone),\n $phoneNational,\n ])\n ->filter() // Removes null and empty strings\n ->unique()\n ->values();\n\n foreach ($possiblePhoneFormats as $phone) {\n $sosl = $queryBuilder->buildMatchByQuery($phone, Field::TYPE_PHONE);\n if ($sosl === null) {\n continue;\n }\n\n try {\n $objects = $this->queryHandler->search($sosl);\n $objects = $this->queryHandler->prioritiseResults(\n $objects,\n $phone,\n QueryHandler::PRIORITISE_PHONE\n );\n\n return $this->convertCrmData($objects, $userId);\n } catch (NoResultsException) {\n continue;\n }\n }\n\n return null;\n }\n\n private function isPhoneNumberOfTeamMember(string $phone): bool\n {\n $teamRepository = app(TeamRepository::class);\n $user = $teamRepository->findTeamMemberByPhone($this->team, $phone);\n\n if ($user instanceof User) {\n return true;\n }\n\n return false;\n }\n\n protected function getCacheKey(string $object, ?int $userId = null): ?string\n {\n $key = $this->profile->id . $object;\n $keySuffix = $this->getOwnerKeySuffix($userId);\n\n return $key . $keySuffix;\n }\n\n private function getOwnerKeySuffix(?int $userId = null): string\n {\n return $userId === null ? '' : (string) $userId;\n }\n\n /** Determine the CRM Objects which represent the call activity. */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n if ($this->profile === null) {\n return null;\n }\n\n $cacheKey = $this->getCacheKey($name, $userId);\n\n $result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $sosl = $queryBuilder->buildMatchByQuery($name, 'name');\n if ($sosl === null) {\n return false;\n }\n\n try {\n $objects = $this->queryHandler->search($sosl);\n } catch (NoResultsException $e) {\n return false;\n }\n\n $objects = $this->queryHandler->prioritiseResults(\n $objects,\n $name,\n QueryHandler::PRIORITISE_NAME\n );\n\n $data = $this->convertCrmData($objects, $userId);\n\n return (! empty(array_filter($data))) ? $data : false;\n });\n\n return is_array($result) ? $result : null;\n }\n\n /**\n * @return array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n protected function convertCrmData(QueryIterator $objects, ?int $userId = null): array\n {\n $lead = null;\n $contact = null;\n $opportunity = null;\n $account = null;\n $stage = null;\n $countryCode = null;\n\n if ($objects->count() > 0) {\n $object = $objects->current();\n\n if ($object['attributes']['type'] === 'Lead') {\n $lead = $this->importLead($object);\n\n // Lead might not be imported if the Stage is null for example.\n if ($lead) {\n $countryCode = $lead->country_code;\n $stage = $lead->stage;\n }\n } else {\n if ($object['attributes']['type'] === 'Contact') {\n $contact = $this->importContact($object);\n $account = $contact->account;\n } else {\n $account = $this->importAccount($object);\n }\n\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account) {\n $countryCode = $account->country_code;\n }\n\n try {\n $sfOpportunities = $this->findOpportunities(\n $account?->getCrmProviderId(),\n $contact?->getCrmProviderId(),\n $userId\n );\n\n // Take the first opportunity, which will be ordered as priority based on their settings.\n if (! empty($sfOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($sfOpportunities[0]['crmId']);\n $stage = $opportunity?->stage;\n }\n } catch (Exception) {\n // Nothing to see here.\n }\n }\n }\n\n return [\n $lead,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n /**\n * @inheritdoc\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n if ($stage->type === Stage::TYPE_LEAD) {\n $objectType = 'Lead';\n $objectId = $crmObject->crm_provider_id;\n $objectStageType = 'Status';\n } else {\n $objectType = 'Opportunity';\n $objectId = $crmObject->crm_provider_id;\n $objectStageType = 'StageName';\n }\n\n $headers = [];\n if ($this->config->trigger_assignment_rules === false) {\n // @see: https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/headers_autoassign.htm\n $headers = [\n 'Sforce-Auto-Assign' => 'false',\n ];\n }\n\n $this->updateRecord($objectType, $objectId, [$objectStageType => $stage->name], $headers);\n }\n\n public function parseObjectType(string $objectId): string\n {\n if (Str::startsWith($objectId, '001')) {\n return 'account';\n }\n\n if (Str::startsWith($objectId, '003')) {\n return 'contact';\n }\n\n if (Str::startsWith($objectId, '00Q')) {\n return 'lead';\n }\n\n if (Str::startsWith($objectId, '006')) {\n return 'opportunity';\n }\n\n if (Str::startsWith($objectId, '00U')) {\n return 'event';\n }\n\n if (Str::startsWith($objectId, '00T')) {\n return 'task';\n }\n\n throw new \\InvalidArgumentException('Unsupported Object Type');\n }\n\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, ['profile' => $this->profile]);\n $query = $queryBuilder->buildGetUsersQuery($userToSearch);\n\n try {\n $salesforceUsers = $this->queryHandler->query($query, [\n 'active' => true,\n ]);\n } catch (NoResultsException $e) {\n $this->logger->info('[Salesforce] Sync Profiles. No users found', [\n 'query' => $query,\n 'error' => $e->getMessage(),\n ]);\n\n return null;\n }\n\n $teamRepository = app(TeamRepository::class);\n $customRules = $this->getCustomProfileRules($teamRepository);\n\n foreach ($salesforceUsers as $crmUser) {\n if ($crmUser['Email'] === null) {\n continue;\n }\n\n if (! $this->customProfileValidation($crmUser, $customRules)) {\n continue;\n }\n\n $user = $teamRepository->findActiveTeamMemberByEmail($this->team, $crmUser['Email']);\n\n if (! $user instanceof User) {\n continue;\n }\n\n $edition = $crmUser['UserPreferencesLightningExperiencePreferred']\n ? Profile::EDITION_LIGHTNING\n : Profile::EDITION_CLASSIC;\n\n $profileRepository = app(ProfileRepository::class);\n $profile = $profileRepository->updateOrCreateProfile(\n $user,\n [\n 'crm_configuration_id' => $this->config->getId(),\n 'crm_provider_id' => $crmUser['Id'],\n ],\n [\n 'user_id' => $user->getId(),\n 'edition' => $edition,\n 'has_external_cti' => ! empty($crmUser['CallCenterId']),\n 'crm_profile_id' => $crmUser['ProfileId'],\n ]\n );\n\n if ($userToSearch instanceof User && $userToSearch->getId() === $user->getId()) {\n return $profile;\n }\n }\n\n // Clean up inactive profiles\n try {\n $this->archiveInactiveProfiles();\n } catch (\\Exception $e) {\n $this->logger->warning('[Salesforce] Profile archiving failed', [\n 'teamId' => $this->team->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $url = null;\n\n // For Salesforce it's easy, we just point every object to the apex domain and they handle it.\n switch ($objectType) {\n case 'lead':\n case 'account':\n case 'contact':\n case 'opportunity':\n case 'task':\n case 'event':\n case 'activity':\n\n $url = $this->config->crm_base_url . '/' . $providerId;\n\n break;\n }\n\n return $url;\n }\n\n public function buildTaskSearchFields(): array\n {\n return ['Id', 'WhoId', 'WhatId', 'AccountId'];\n }\n\n public function getTaskByFilterConditions(\n array $fields,\n array $filters,\n bool $bulkSearch = false,\n bool $strictFilters = true\n ): ?array {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $query = $queryBuilder->buildSearchTaskQuery($fields, $filters, $bulkSearch, $strictFilters);\n\n try {\n if (! $bulkSearch) {\n $objects = $this->queryHandler->query($query, $filters);\n if ($objects->count() === 1) {\n return $objects->current();\n }\n }\n\n if ($bulkSearch) {\n $objects = $this->queryHandler->query($query);\n $records = [];\n foreach ($objects as $record) {\n $key = $record[end($fields)];\n $records[$key] = $record;\n }\n\n return $records;\n }\n } catch (\\Exception $e) {\n $this->logger->info('[Salesforce] Failed to execute query', [\n 'query' => $query,\n 'error' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function mapCrmObjects(array $task): array\n {\n $activityData = [];\n\n if (! empty($task['WhoId'])) {\n $type = $this->parseObjectType($task['WhoId']);\n $activityData[$type] = $task['WhoId'];\n }\n if (! empty($task['AccountId'])) {\n $activityData['account'] = $task['AccountId'];\n }\n if (! empty($task['WhatId'])) {\n $activityData['opportunity'] = $task['WhatId'];\n }\n\n return $activityData;\n }\n\n /**\n * Get SF task by Outreach call id.\n */\n public function getTaskByFilter(\n string $activityFieldType,\n array $filters,\n string $operator = '=',\n array $additionalFields = []\n ): ?array {\n $data = [];\n\n try {\n // Default (base) fields.\n $fields = ['Id', 'Subject', 'Description', 'ActivityDate', 'WhoId', 'WhatId', $activityFieldType];\n\n foreach ($additionalFields as $additionalField) {\n $fields[] = $additionalField->crm_provider_id;\n }\n\n $fields = array_unique($fields);\n\n // Find task with the same Outreach id as the call id.\n $query = 'SELECT ' . implode(',', $fields) . '\n FROM Task\n WHERE IsArchived = false AND IsDeleted = false';\n\n foreach ($filters as $key => $value) {\n $key = preg_quote($key, '/');\n $key = str_replace(['\\'', '\"'], '', $key);\n // Prepare the substitution.\n $strKey = \":$key\";\n\n $query .= \" AND $key $operator $strKey\";\n }\n\n $query .= ' ORDER BY LastModifiedDate DESC LIMIT 1';\n\n $objects = $this->queryHandler->query($query, $filters);\n\n // There should be only one task related to this call if any.\n if ($objects->count() === 1) {\n $object = $objects->current();\n\n $dueDate = $object['ActivityDate'] ? Carbon::parse($object['ActivityDate'])->toIso8601String() : null;\n\n $data = array_merge($object, [\n 'crmId' => $object['Id'],\n 'subject' => $object['Subject'],\n 'summary' => $object['Description'],\n 'due' => $dueDate,\n 'Type' => $object[$activityFieldType],\n ]);\n }\n } catch (NoResultsException $e) {\n // Filters don't match any records.\n } catch (ServiceUnavailableException $serviceUnavailableException) {\n // Service cannot be queried. We should probably log this.\n }\n\n return $data;\n }\n\n /**\n * Get Salesforce fields including datetime fields\n *\n * @param $objectType\n */\n private function getAllFieldsAsArray($objectType): array\n {\n $basicFields = [];\n // Not all users have access to all object fields.\n if ($this->profile->{$objectType . '_fields'}) {\n $basicFields = explode(',', $this->profile->{$objectType . '_fields'});\n }\n\n $extraFields = [\n 'CreatedDate',\n 'LastModifiedDate',\n 'IsDeleted',\n ];\n\n if ($objectType === self::OBJECT_OPPORTUNITY\n && $this->config->opportunity_value_field_id\n && ! in_array($this->config->opportunityValueField->crm_provider_id, $basicFields)\n ) {\n $extraFields[] = $this->config->opportunityValueField->crm_provider_id;\n }\n\n return array_unique(array_merge($basicFields, $extraFields));\n }\n\n /**\n * Generate transcription for activity description.\n */\n private function generateTranscription(Activity $activity): string\n {\n if (! ($this->config->store_transcript)) {\n // If sending transcription to activity toggle is disabled\n return '';\n }\n\n return $this->transcriptionService\n ->findTranscriptionByActivity($activity)\n ->map(static function (array $transcriptionSegment): string {\n return $transcriptionSegment['formattedStartsAt'] . ' | ' . $transcriptionSegment['transcript'];\n })\n ->implode(PHP_EOL);\n }\n\n /**\n * Find related Salesforce event based on activity data\n *\n * @return array<string>\n */\n public function fetchRelatedActivity(Activity $activity): array\n {\n $this->logger->info('[Salesforce] Searching for related activity', [\n 'activityId' => $activity->getUuid(),\n 'ownerId' => $this->profile?->crm_provider_id,\n ]);\n\n $sfEvent = $this->fetchRelatedEvent($activity);\n if (empty($sfEvent)) {\n $this->logger->info('[Salesforce] No related activity found', [\n 'activityId' => $activity->getUuid(),\n 'ownerId' => $this->profile?->crm_provider_id,\n 'account' => $activity->hasAccount()\n ? $activity->getAccount()->getCrmProviderId()\n : null,\n ]);\n\n return [];\n }\n\n return $sfEvent;\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n if ($activity->isTypeConference() === false) {\n return null;\n }\n\n if ($activity->hasActualStartTime() === false && $activity->hasScheduledStartTime() === false) {\n return null;\n }\n\n if (! $activity->hasProspect()) {\n $this->logger->info('[Salesforce] Skip look up, Activity not linked to Lead, Contact or Account', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n $playbook = $this->getPlaybook($activity->getUser());\n if ($playbook !== null && $playbook->getActivityType() === Playbook::ACTIVITY_TYPE_TASK) {\n $this->logger->info('[Salesforce] Skip auto-sync for task-based playbook', [\n 'activityUuid' => $activity->getUuid(),\n 'playbookId' => $playbook->getId(),\n 'playbookType' => $playbook->getActivityType(),\n ]);\n\n return null;\n }\n\n try {\n $sfEvent = $this->fetchRelatedActivity($activity);\n if (empty($sfEvent)) {\n return null;\n }\n\n [$activityField, $activityType] = $this->resolveActivityTypeFromEvent($activity, $sfEvent);\n\n $this->logger->info('[Salesforce] Found related activity', [\n 'activityId' => $activity->getUuid(),\n 'sfEvent' => $sfEvent['Id'],\n 'activityFieldName' => $activityField,\n 'crmActivityType' => ($activityField !== null && isset($sfEvent[$activityField]))\n ? $sfEvent[$activityField]\n : null,\n 'activityType' => $activityType,\n ]);\n\n $userId = $this->findRelatedActivityUserId($activity, $sfEvent);\n\n if ($activity->getUserId() !== $userId) {\n $this->logger->info('[Salesforce] Updating meeting owner', [\n 'activityId' => $activity->getUuid(),\n 'oldUserId' => $activity->getUserId(),\n 'newUserId' => $userId,\n ]);\n }\n\n $this->updateSfEventDescription($activity, $sfEvent);\n\n $activity->update([\n 'user_id' => $userId,\n 'crm_provider_id' => $sfEvent['Id'],\n 'playbook_category_id' => $activityType->id ?? $activity->getCategory()?->getId(),\n ]);\n\n $this->logger->info('[Salesforce] Activity updated', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return $activity;\n } catch (\\Exception $exception) {\n \\Sentry::captureException($exception);\n\n throw $exception;\n }\n }\n\n /**\n * @param array<string, mixed> $sfEvent\n *\n * @return array{0: string|null, 1: mixed}\n */\n private function resolveActivityTypeFromEvent(Activity $activity, array $sfEvent): array\n {\n $activityField = $this->getActivityFieldName($activity);\n $activityType = null;\n\n if ($activityField !== null && ! empty($sfEvent[$activityField])) {\n $playbook = $this->getPlaybook($activity->getUser());\n $activityType = $this->getPlaybookCategory($playbook, strval($sfEvent[$activityField]));\n }\n\n return [$activityField, $activityType];\n }\n\n /**\n * @param array<string> $sfEvent\n */\n private function findRelatedActivityUserId(Activity $activity, array $sfEvent): int\n {\n $userId = $activity->getUserId();\n\n if (empty($sfEvent['OwnerId']) === false) {\n $profile = $this\n ->config\n ->profiles()\n ->where('crm_provider_id', $sfEvent['OwnerId'])\n ->get()\n ->filter(static function (Profile $profile) use ($activity): bool {\n if (! $activity->isTypeConference()) {\n return ! empty($profile->user) ? $profile->user->isStatusActive() : false;\n }\n\n $participants = $activity->getParticipants();\n\n return ! empty($profile->user)\n ? $profile->user->isStatusActive()\n && $profile->user->hasPermission(PermissionEnum::RECORD_MEETING)\n && $participants->contains('user_id', $profile->user_id)\n : false;\n })\n ->first();\n\n if ($profile) {\n $userId = $profile->user_id;\n }\n }\n\n return $userId;\n }\n\n /**\n * @param array<string, mixed> $sfEvent\n */\n private function updateSfEventDescription(Activity $activity, array $sfEvent): void\n {\n try {\n if (str_contains($sfEvent['Description'], $activity->id_string)) {\n return;\n }\n\n $payload = [\n 'Description' => $sfEvent['Description']\n . PHP_EOL\n . PHP_EOL\n . (new DecorateActivity())->generateDescription($activity),\n ];\n\n $this->logger->info('[Salesforce] Update record', [\n 'activityId' => $activity->getUuid(),\n 'sfEvent' => $sfEvent['Id'],\n 'payload' => $payload,\n ]);\n\n $payload = array_merge(\n $payload,\n $this->payloadBuilder->fetchCustomFieldData($activity, Field::OBJECT_EVENT)\n );\n\n $this->updateRecord('Event', $sfEvent['Id'], $payload);\n } catch (\\Exception) {\n $this->logger->error('[Salesforce] Failed to update record', [\n 'activityUuid' => $activity->getUuid(),\n 'sfEvent' => $sfEvent['Id'],\n ]);\n }\n }\n\n /**\n * Returns the most recently modified Event within time range (if any).\n *\n * @return array|null An Event record from Salesforce.\n */\n private function fetchRelatedEvent(Activity $activity): ?array\n {\n $ownerId = $this->profile?->crm_provider_id;\n if ($ownerId === null) {\n return [];\n }\n\n /** @var ?Carbon $from */\n /** @var ?Carbon $to */\n [$from, $to] = $this->getFromToDates($activity);\n\n try {\n $whoId = null;\n $hasWho = $activity->lead_id || $activity->contact_id;\n if ($hasWho) {\n $whoId = $activity->hasLead()\n ? $activity->getLead()->crm_provider_id\n : $activity->getContact()->crm_provider_id;\n }\n\n if ($hasWho === false && $activity->account_id === null) {\n return null;\n }\n\n $query = $this->buildFetchRelatedEventQuery($activity);\n\n $objects = $this->queryHandler->query($query, [\n 'ownerId' => $ownerId,\n 'whoId' => $whoId,\n 'whatId' => $activity->hasOpportunity() ? $activity->getOpportunity()->crm_provider_id : null,\n 'accountId' => $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null,\n 'from' => $from?->format('Y-m-d\\TH:i:s\\Z'),\n 'to' => $to?->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($objects as $object) {\n return $object;\n }\n } catch (NoResultsException $e) {\n return [];\n }\n\n return [];\n }\n\n private function getFromToDates(Activity $activity): array\n {\n $from = null;\n $to = null;\n\n /** @var ?CalendarEvent $calendarEvent */\n $calendarEvent = $activity->calendarEvent()->first();\n if ($calendarEvent !== null) {\n $from = $calendarEvent->getStartTime();\n $to = $calendarEvent->getEndTime();\n }\n\n // For non-calendar imported activities\n // Also double check if calendar event dates could be null?\n // If null use what we've got so far\n if ($from === null || $to === null) {\n $from = $activity->hasScheduledStartTime()\n ? $activity->getScheduledStartTime()\n : $activity->getActualStartTime();\n $to = $activity->hasScheduledEndTime()\n ? $activity->getScheduledEndTime()->addMinutes(15)\n : $activity->getActualEndTime();\n }\n\n return [$from, $to];\n }\n\n /**\n * Determines the appropriate activity field name for querying Salesforce events.\n *\n * This method follows a hierarchy to determine the field name:\n * 1. Uses the playbook's activity field if it exists and is in the profile's accessible fields\n * 2. Falls back to the default activity field if the profile has no event fields configured\n * 3. Returns null if no suitable field is found\n *\n * @param Activity $activity The activity to determine the field for\n *\n * @return string|null The field name to use in queries, or null if none is available\n */\n private function getActivityFieldName(Activity $activity): ?string\n {\n if ($this->profile === null) {\n $this->logger->warning('[Salesforce] Cannot determine activity field - profile not found', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n $profileEventFields = $this->profile->getFieldsAsArray('event');\n\n if (empty($profileEventFields)) {\n $defaultActivityField = $this->getDefaultActivityField(Field::OBJECT_EVENT);\n $defaultFieldName = $defaultActivityField?->getAttribute('crm_provider_id');\n // Profile not yet synced — fall back to the default activity field.\n // There is a small chance that the profile won't have Default Activity Type field access\n // in which case the query will fail.\n // This is however an edge case and should be reviewed for profile sync issues.\n Sentry::withScope(function (\\Sentry\\State\\Scope $scope) use ($defaultFieldName): void {\n $scope->setContext('details', [\n 'profileId' => $this->profile->id,\n 'defaultField' => $defaultFieldName,\n ]);\n Sentry::captureMessage(\n '[Salesforce] Profile event fields empty, falling back to default activity field.',\n \\Sentry\\Severity::warning()\n );\n });\n\n return $defaultFieldName;\n }\n\n $playbook = $this->getPlaybook($activity->getUser());\n\n if (! is_null($playbook) && ! is_null($playbook->getActivityField())) {\n $playbookFieldName = $playbook->getActivityField()->getAttribute('crm_provider_id');\n\n if (in_array($playbookFieldName, $profileEventFields, true)) {\n return $playbookFieldName;\n }\n\n $this->logger->warning('[Salesforce] Playbook activity field not found in profile fields', [\n 'activityId' => $activity->getUuid(),\n 'playbookField' => $playbookFieldName,\n 'profileId' => $this->profile->id,\n ]);\n }\n\n return null;\n }\n\n private function buildFetchRelatedEventQuery(Activity $activity): string\n {\n $hasWho = $activity->lead_id || $activity->contact_id;\n\n $activityFieldName = $this->getActivityFieldName($activity);\n $fields = array_filter(['Id', 'Description', 'OwnerId', $activityFieldName]);\n\n $ownerCondition = '(OwnerId = :ownerId OR CreatedById = :ownerId)';\n\n $query = '\n SELECT ' . implode(',', $fields) . '\n FROM Event\n WHERE ' . $ownerCondition . '\n AND IsArchived = false\n AND IsAllDayEvent = false\n AND StartDateTime >= :from\n AND EndDateTime <= :to\n AND (';\n\n $operator = '';\n if ($activity->account_id) {\n // This covers events tied to a related contact or opportunity too.\n $query .= 'AccountId = :accountId';\n\n $operator = ' OR ';\n }\n\n if ($hasWho) {\n $query .= $operator . 'WhoId = :whoId';\n\n // If we are also going to check on a specific opportunity, set that up.\n if ($activity->opportunity_id) {\n $query .= ' OR WhatId = :whatId';\n }\n }\n\n $query .= ') ORDER BY LastModifiedDate DESC';\n\n return $query;\n }\n\n public function fetchProspect(array $task): array\n {\n $lead = $account = $opportunity = $contact = $stage = $countryCode = null;\n $externalId = $task['WhoId'] ?? null;\n\n // Lead or Contact\n if ($externalId) {\n try {\n [$lead, $account, $opportunity, $contact, $stage, $countryCode] = $this->parseRecords($externalId);\n } catch (\\InvalidArgumentException $exception) {\n // Invalid object type.\n }\n }\n\n // If we happen to know the opportunity or account from the Task, figure that out.\n if (empty($task['WhatId']) === false) {\n // WhatId could be either Account ID or Opportunity ID.\n // If WhatId is Opportunity ID, get the opportunity and stage from the CRM.\n try {\n [, $account, $opportunity, , $stage, ] = $this->parseRecords($task['WhatId']);\n } catch (\\InvalidArgumentException $exception) {\n // Invalid object type.\n }\n }\n\n return [$lead, $account, $opportunity, $contact, $stage, $countryCode];\n }\n\n /**\n * Save activity transcription summary as note\n */\n public function saveTranscriptionSummaryAsNote(\n ActivityContract $activity,\n string $title,\n string $body,\n ?string $objectId,\n ?NoteObject $noteObject = null,\n ): ?string {\n return $this->saveNote($title, $body, (string) $objectId);\n }\n\n public function getObjectByFilterConditions(string $objectType, array $fields, array $filters): ?array\n {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $query = $queryBuilder->buildObjectSearchQuery($objectType, $fields, $filters);\n\n try {\n $objects = $this->queryHandler->query($query, $filters);\n if ($objects->count() === 1) {\n return $objects->current();\n }\n } catch (\\Exception $e) {\n $this->logger->info('[Salesforce] Failed to execute query', [\n 'query' => $query,\n 'error' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n private function getCustomProfileRules(TeamRepository $teamRepository): array\n {\n $teamSettings = $teamRepository->getTeamSetting($this->team, 'custom_profile_validation');\n\n if ($teamSettings instanceof TeamSettings && $teamSettings->getValueType() === 'array') {\n $customRules = json_decode($teamSettings->getValue(), true);\n if (is_array($customRules)) {\n return $customRules;\n }\n }\n\n return [];\n }\n\n private function customProfileValidation(array $crmUser, array $customRules): bool\n {\n foreach ($customRules as $customRule) {\n if ($crmUser[$customRule['field']] !== $customRule['value']) {\n return false;\n }\n }\n\n return true;\n }\n\n /**\n * When syncing Contact / Lead / Account / Opportunity / Stage crm entities,\n * validate and restore locally trashed objects,\n * before updating them. Objects are identified by CrmProviderId\n */\n private function restoreAnyTrashedEntity(HasMany $targetEntity, string $crmProviderId): void\n {\n $recordExists = $targetEntity->withTrashed()->where(['crm_provider_id' => $crmProviderId])->first();\n if ($recordExists && $recordExists->trashed()) {\n $recordExists->restore();\n }\n }\n\n #[\\Override] public function supportsNotes(): bool\n {\n return true;\n }\n\n private function getOwnerProfile(?string $ownerId): ?Profile\n {\n if ($ownerId === null) {\n return null;\n }\n\n return $this->config->profiles()\n ->where('crm_provider_id', $ownerId)\n ->first();\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"Current version","depth":1,"bounds":{"left":0.4398271,"top":0.43415803,"width":0.13863032,"height":0.013567438},"on_screen":true,"value":"Current version","help_text":"text/plain","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Salesforce;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse Illuminate\\Database\\Eloquent\\Relations\\HasMany;\nuse Illuminate\\Events\\Dispatcher;\nuse Illuminate\\Support\\Facades\\Cache;\nuse Illuminate\\Support\\Facades\\Log;\nuse Illuminate\\Support\\Str;\nuse Jiminny\\Component\\Country\\CountriesMap;\nuse Jiminny\\Contracts\\Acl\\PermissionEnum;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\FetchRelatedActivityInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\ImportsBusinessProcessesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\SalesforceBatchSyncInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\SalesforceInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteNoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SearchTaskInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SendSummaryToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SupportsObjectTypeParseInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmProfileRecordTypesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Enums\\CrmObject;\nuse Jiminny\\Events\\Activities\\Crm\\LeadConverted;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpBadRequestException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Exceptions\\NoResultsException;\nuse Jiminny\\Exceptions\\ServiceUnavailableException;\nuse Jiminny\\Jobs\\Crm\\NoteObject;\nuse Jiminny\\Jobs\\Crm\\MatchActivitiesToNewOpportunity;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Calendar\\CalendarEvent;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Contracts\\ActivityContract;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Configuration;\nuse Jiminny\\Models\\Crm\\ContactRole;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Crm\\RecordType;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\TeamSettings;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\ContactRoleRepository;\nuse Jiminny\\Repositories\\Crm\\FieldRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Repositories\\Crm\\RecordTypeFieldValuesRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\Helpers\\ArrayIterator;\nuse Jiminny\\Services\\Crm\\MatchDomainByEmailInterface;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Services\\Crm\\ResolveCompanyNameByEmailTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\Fields\\FieldHelper;\nuse Jiminny\\Services\\Crm\\Salesforce\\Fields\\FieldTypeConverter;\nuse Jiminny\\Services\\Crm\\Salesforce\\Fields\\ValueNormalizer;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\FollowupActivityTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\LogActivityTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\RecordManipulationsTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\SyncFieldsTrait;\nuse Jiminny\\Utils\\CurrencyFormatter;\nuse Jiminny\\Utils\\StringUtil;\nuse Ramsey\\Uuid\\Uuid;\nuse Sentry\\Laravel\\Facade as Sentry;\n\nclass Service extends BaseService implements\n SalesforceInterface,\n SalesforceBatchSyncInterface,\n SyncCrmEntitiesInterface,\n SyncCrmProfileRecordTypesInterface,\n ImportsBusinessProcessesInterface,\n RemoteEntityManipulationInterface,\n FetchRelatedActivityInterface,\n SendSummaryToCrmInterface,\n MatchDomainByEmailInterface,\n SearchTaskInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n SupportsObjectTypeParseInterface,\n RemoteNoteEntityManipulationInterface,\n VerifyTaskExistsInterface\n{\n use ResolveCompanyNameByEmailTrait;\n use SyncFieldsTrait;\n use DeleteObjectsTrait;\n use RecordManipulationsTrait;\n use ServiceTraits\\BatchSyncTrait;\n use FollowupActivityTrait;\n use LogActivityTrait;\n\n /**\n * Note Body Limit for the Old Note-Taking Tool\n *\n * @var int\n */\n private const int CLASSIC_NOTE_MAX_LENGTH = 32000;\n\n /**\n * Note Content Limit for the New Notes\n *\n * @var int\n */\n private const int ENHANCED_NOTE_MAX_LENGTH = 50000000;\n\n private const string INSTALLED_PACKAGE_ID = '033Tw0000007bKbIAI';\n\n private const int CACHE_TTL = 600;\n\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day - 86400\n\n /**\n * @var Client\n */\n protected $client;\n\n protected PayloadBuilder $payloadBuilder;\n protected QueryHandler $queryHandler;\n\n private OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n\n public function __construct(\n Client $client,\n PayloadBuilder $payloadBuilder,\n protected Dispatcher $eventDispatcher,\n private readonly CountriesMap $countriesMap,\n private readonly ProspectPhotoPathService $prospectPhotoPathService,\n ) {\n parent::__construct();\n\n $this->client = $client;\n $this->payloadBuilder = $payloadBuilder;\n $this->queryHandler = app(QueryHandler::class, [\n 'client' => $this->client,\n 'logger' => $this->logger,\n ]);\n $this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n }\n\n public function getDisplayName(): string\n {\n return 'Salesforce';\n }\n\n public function getJobDelay(): int\n {\n return 1;\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n return $user->getSocialAccount(SocialAccount::PROVIDER_SALESFORCE);\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($activity, $crmProviderId) {\n $playbook = $this->getPlaybookFromActivity($activity);\n\n if ($playbook === null) {\n $this->logger->warning('[Salesforce] Cannot verify task - no playbook found', [\n 'activity' => $activity->getId(),\n 'crm_provider_id' => $crmProviderId,\n ]);\n\n return false;\n }\n\n $objectType = $playbook->getActivityType() === Playbook::ACTIVITY_TYPE_EVENT ? 'Event' : 'Task';\n\n try {\n $record = $this->getRecord($objectType, $crmProviderId, ['Id', 'IsDeleted']);\n\n return ! empty($record) && ($record['IsDeleted'] ?? false) === false;\n } catch (HttpNotFoundException|HttpBadRequestException) {\n $this->logger->info('[Salesforce] Activity record not found during verification', [\n 'activity' => $activity->getId(),\n 'object_type' => $objectType,\n 'crm_provider_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n });\n }\n\n public function query(string $queryToRun, array $parameters = []): QueryIterator\n {\n // Due to poorly designed external calls, this method cannot be entirely removed\n return $this->queryHandler->query($queryToRun, $parameters);\n }\n\n /*=========== Organization Information ===============*/\n\n /**\n * Get a list of all the API Versions for the instance.\n *\n * @throws CrmException\n *\n * @return mixed\n *\n */\n public function getApiVersions()\n {\n $url = $this->config->crm_base_url . '/services/data';\n\n $response = $this->client->get($url);\n\n return json_decode($response->getBody(), true);\n }\n\n /**\n * Gets the valid recordTypes for a given Salesforce Object via the describe API.\n */\n private function getRecordTypes(string $crmObject): array\n {\n $url = $this->client->getObjectsUrl() . $crmObject . '/describe';\n\n $response = $this->client->get($url);\n $jsonResponse = json_decode($response->getBody(), true);\n\n $fields = [];\n foreach ($jsonResponse['recordTypeInfos'] as $row) {\n $fields[] = ['recordTypeId' => $row['recordTypeId'], 'default' => $row['defaultRecordTypeMapping']];\n }\n\n return $fields;\n }\n\n /**\n * Convert raw field data into a format compatible with CRM APIs.\n */\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return ValueNormalizer::normalize($fieldType, $fieldValue, $internal);\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n $defaultFields = ($activityType === Playbook::ACTIVITY_TYPE_TASK)\n ? FieldDefinitions::defaultTaskFields()\n : FieldDefinitions::defaultEventFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n // Setup the activity field as the default Type.\n /** @var Field $activityField */\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'Type',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK, Playbook::ACTIVITY_TYPE_EVENT];\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n\n $fieldFilter = ($activityType === Playbook::ACTIVITY_TYPE_TASK)\n ? FieldDefinitions::taskFollowupFieldsFilter()\n : FieldDefinitions::eventFollowupFieldsFilter();\n\n foreach ($fieldFilter as $eachFilter) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $eachFilter);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n /**\n * This one is now called only when ImportActivityTypes is triggered or SyncFieldMetadata executed manually\n * Regular sync now uses SharedSyncFieldsTrait -> syncSingleObjectType\n * Needs to be replaced later on\n */\n public function syncField(Field $field): void\n {\n try {\n if (FieldHelper::isCustomField($field)) {\n $query = '\n SELECT\n Id, Metadata, TableEnumOrId\n FROM\n CustomField\n WHERE\n DeveloperName = :fieldName\n AND\n TableEnumOrId = :fieldType\n AND\n NamespacePrefix = :namespacePrefix';\n\n // We need to constrain the field lookup to the object, in case it's used in multiple places.\n $objectType = \\in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true)\n ? 'activity'\n : $field->object_type;\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'fieldName' => substr($field->crm_provider_id, 0, -\\strlen('__c')),\n 'fieldType' => ucfirst($objectType),\n\n // This is used to ensure we only consider the field within the org, not installed packages.\n 'namespacePrefix' => 'null',\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n // Sync field metadata.\n $metadata = $sfField['Metadata'];\n\n $field->description = mb_strimwidth($metadata['description'] ?? '', 0, 191);\n $field->label = mb_strimwidth($metadata['label'] ?? '', 0, Field::LABEL_MAX_LENGTH);\n $field->type = $this->convertFieldType($metadata['type'], $field->getEntityName());\n $field->is_mandatory = ($metadata['required'] === true);\n $field->length = $metadata['length'];\n $field->default_value = mb_strimwidth(trim($metadata['defaultValue'] ?? '', '\"'), 0, 191);\n $field->save();\n } else {\n $query = '\n SELECT\n Id, DataType, DeveloperName, Label, Length, Description\n FROM\n FieldDefinition\n WHERE\n DurableId = :entityName';\n\n $entityName = $field->getEntityName();\n $sfFields = $this->queryHandler->metadata($query, [\n 'entityName' => $entityName,\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n $convertedType = $this->convertFieldType($sfField['DataType'], $entityName);\n $label = mb_strimwidth($sfField['Label'], 0, Field::LABEL_MAX_LENGTH);\n\n if ($field->isBusinessType()) {\n $label = 'Opportunity Type';\n }\n\n $field->description = mb_strimwidth($sfField['Description'], 0, Field::DESCRIPTION_MAX_LENGTH);\n $field->label = $label;\n $field->type = $convertedType;\n $field->length = $sfField['Length'];\n $field->save();\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n private function convertFieldType(string $from, ?string $entityName = null): string\n {\n $converter = new FieldTypeConverter();\n\n return $converter->convert($from, $entityName);\n }\n\n /**\n * @inheritdoc\n */\n public function importPicklistValues(Field $field): array\n {\n $values = [];\n $fieldValues = [];\n\n try {\n if (FieldHelper::isCustomField($field)) {\n $query = '\n SELECT\n Id, Metadata, TableEnumOrId\n FROM\n CustomField\n WHERE\n DeveloperName = :fieldName\n AND\n TableEnumOrId = :fieldType\n AND\n NamespacePrefix = :namespacePrefix';\n\n // We need to constrain the field lookup to the object, in case it's used in multiple places.\n $objectType = \\in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true) ?\n 'activity' : $field->object_type;\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'fieldName' => substr($field->crm_provider_id, 0, -\\strlen('__c')),\n 'fieldType' => ucfirst($objectType),\n // This is used to ensure we only consider the field within the org, not installed packages.\n 'namespacePrefix' => 'null',\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n $valueSet = $sfField['Metadata']['valueSet'];\n\n if ($valueSet['valueSetName'] === null) {\n // Local picklist values can be obtained easily.\n $picklistValues = $valueSet['valueSetDefinition']['value'];\n } else {\n // But for some fields, we just get the Global Value Picklist pointer so need to do more work.\n $picklistValues = $this->importGlobalValuePicklistValues($valueSet['valueSetName']);\n }\n\n // Import all active values.\n foreach ($picklistValues as $i => $sfFieldValue) {\n // Setup default value.\n if ($sfFieldValue['default']) {\n $field->update(['default_value' => $sfFieldValue['valueName']]);\n }\n\n // This comes through as null if active (lol).\n if ($sfFieldValue['isActive'] !== false) {\n $values[] = [\n 'value' => $sfFieldValue['valueName'],\n 'label' => $sfFieldValue['valueName'],\n 'sequence' => $i,\n 'is_default' => $sfFieldValue['default'],\n ];\n }\n }\n } else {\n $objectFields = $this->getObjectFields($field->object_type);\n $fieldId = $field->crm_provider_id;\n\n // Only work with our field of interest.\n $objectField = array_filter($objectFields, function ($item) use ($fieldId) {\n return $item['name'] === $fieldId;\n });\n\n $objectField = array_shift($objectField);\n if (empty($objectField['picklistValues']) === false) {\n foreach ($objectField['picklistValues'] as $i => $sfFieldValue) {\n // Skip inactive values.\n if ($sfFieldValue['active'] === false) {\n continue;\n }\n\n // Setup default value.\n if ($sfFieldValue['defaultValue']) {\n $field->update(['default_value' => $sfFieldValue['value']]);\n }\n\n $values[] = [\n 'value' => $sfFieldValue['value'],\n 'label' => $sfFieldValue['label'],\n 'sequence' => $i,\n 'is_default' => $sfFieldValue['defaultValue'],\n ];\n }\n }\n }\n\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n $value['value'] = substr($value['value'] ?? '', 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n if (($key = array_search($value['value'], $fieldsToPurge, true)) !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n // Get IDs of the values to be deleted\n $valuesToDelete = $field->values()->whereIn('value', $fieldsToPurge);\n $valuesToDeleteIds = $valuesToDelete->pluck('id');\n if (! $valuesToDeleteIds->isEmpty()) {\n $recordTypeFieldValuesRepository = app(RecordTypeFieldValuesRepository::class);\n $recordTypeFieldValuesRepository->deleteForCrmFieldValueIds($valuesToDeleteIds->toArray());\n\n // Now safely delete from crm_field_values\n $valuesToDelete->delete();\n }\n\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n return $fieldValues;\n }\n\n /**\n * Gets values from Global Value Picklists.\n */\n private function importGlobalValuePicklistValues(string $picklistName): array\n {\n $query = '\n SELECT\n Metadata\n FROM\n GlobalValueSet\n WHERE\n DeveloperName = :picklistName\n LIMIT 1';\n\n try {\n $sfValues = $this->queryHandler->metadata($query, [\n 'picklistName' => $picklistName,\n ]);\n\n // There is always 1 result at this point.\n $sfValue = $sfValues->current();\n\n return $sfValue['Metadata']['customValue'];\n } catch (NoResultsException $noResultsException) {\n // Nothing returned.\n\n return [];\n }\n }\n\n /**\n * @inheritdoc\n */\n public function syncProfileRecordTypes(): void\n {\n $objectTypes = [\n 'lead',\n 'account',\n 'contact',\n 'opportunity',\n 'task',\n 'event',\n ];\n\n foreach ($objectTypes as $objectType) {\n try {\n $crmRecordTypes = $this->getRecordTypes(ucfirst($objectType));\n\n foreach ($crmRecordTypes as $crmRecordType) {\n // If the record type is default and not the Master type, set this.\n if ($crmRecordType['default'] && $crmRecordType['recordTypeId'] !== '012000000000000AAA') {\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $crmRecordType['recordTypeId'])\n ->first();\n\n if ($recordType) {\n $this->profile->{$objectType . '_record_type_id'} = $recordType->id;\n }\n }\n }\n } catch (HttpNotFoundException $exception) {\n Log::error('No access to ' . $objectType . ' object, skipping...');\n\n // XXX: should we log this fact somewhere?\n continue;\n }\n }\n\n if ($this->profile->isDirty()) {\n $this->profile->save();\n }\n }\n\n /**\n * Gets business processes.\n */\n public function importBusinessProcesses(): void\n {\n $query = '\n SELECT\n Id, IsActive, Name, TableEnumOrId\n FROM\n BusinessProcess\n WHERE\n TableEnumOrId IN (\\'Lead\\',\\'Opportunity\\')';\n\n try {\n $sfProcesses = $this->queryHandler->query($query);\n\n // Upsert all processes for the team.\n foreach ($sfProcesses as $sfProcess) {\n /** @var BusinessProcess $businessProcess */\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $sfProcess['Id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => $sfProcess['Name'],\n 'type' => $sfProcess['TableEnumOrId'] === 'Lead' ? 'lead' : 'opportunity',\n 'is_selectable' => $sfProcess['IsActive'],\n ]);\n\n $this->importBusinessProcessStages($businessProcess);\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n /**\n * Gets business process stages.\n */\n private function importBusinessProcessStages(BusinessProcess $businessProcess): void\n {\n $query = '\n SELECT\n Metadata\n FROM\n BusinessProcess\n WHERE\n Id = :processId';\n\n try {\n $stages = [];\n $sfProcessStages = $this->queryHandler->metadata($query, [\n 'processId' => $businessProcess->crm_provider_id,\n ]);\n\n // There is always 1 result at this point.\n $sfProcessStage = $sfProcessStages->current();\n\n // Upsert all processes for the team.\n foreach ($sfProcessStage['Metadata']['values'] as $sfProcessStage) {\n $sanitizedName = urldecode($sfProcessStage['valueName']); // Must decode: \"%2C\" becomes \",\" etc.\n\n $stage = $businessProcess->crm->stages()\n // This MUST match on label because this API doesn't use API Name.\n ->where('label', $sanitizedName)\n ->where('type', $businessProcess->type)\n ->where('is_selectable', 1)\n ->first();\n\n if ($stage) {\n $stages[] = $stage->id;\n }\n }\n\n $businessProcess->stages()->sync($stages);\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n /**\n * Gets record types.\n */\n public function importRecordTypes(): void\n {\n $query = '\n SELECT\n Id, IsActive, Name, BusinessProcessId, SobjectType\n FROM\n RecordType';\n\n try {\n $sfRecordTypes = $this->queryHandler->query($query);\n\n // Upsert all record types for the process.\n foreach ($sfRecordTypes as $sfRecordType) {\n $businessProcess = null;\n if ($sfRecordType['BusinessProcessId']) {\n $businessProcess = $this->config->businessProcesses()\n ->where('crm_provider_id', $sfRecordType['BusinessProcessId'])\n ->first();\n }\n\n /** @var RecordType $recordType */\n $recordType = $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $sfRecordType['Id'],\n ], [\n 'team_id' => $this->team->id,\n 'type' => mb_strtolower($sfRecordType['SobjectType']),\n 'name' => $sfRecordType['Name'],\n 'is_selectable' => $sfRecordType['IsActive'],\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n $this->importRecordTypeFieldValues($recordType);\n }\n } catch (NoResultsException $noResultsException) {\n // Do nothing.\n }\n }\n\n /**\n * Import record type - field value mappings. This only works for standard fields.\n */\n private function importRecordTypeFieldValues(RecordType $recordType): void\n {\n try {\n $query = '\n SELECT\n Metadata\n FROM\n RecordType\n WHERE\n Id = :recordTypeId';\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'recordTypeId' => $recordType->crm_provider_id,\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n // Sync field metadata.\n $picklists = $sfField['Metadata']['picklistValues'];\n\n foreach ($picklists as $picklist) {\n $field = $this->config->fields()->where([\n 'type' => Field::TYPE_PICKLIST,\n 'object_type' => $recordType->type,\n 'crm_provider_id' => $picklist['picklist'],\n ])->first();\n\n if ($field) {\n $fieldValues = [];\n\n foreach ($picklist['values'] as $value) {\n // Must decode: \"%2C\" becomes \",\" etc.\n $fieldValue = $field->values()\n ->where('value', urldecode($value['valueName']))\n ->first();\n\n if ($fieldValue) {\n $fieldValues[] = $fieldValue->id;\n }\n }\n\n $recordType->fieldValues()->sync($fieldValues);\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n /**\n * @inheritdoc\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $params = [];\n $missingStage = null;\n if ($types === null) {\n $types = [Stage::TYPE_LEAD, Stage::TYPE_OPPORTUNITY];\n }\n\n foreach ($types as $type) {\n if ($type === Stage::TYPE_LEAD) {\n $query = '\n SELECT\n Id, ApiName, MasterLabel, SortOrder\n FROM\n LeadStatus';\n } else {\n $query = '\n SELECT\n Id, ApiName, MasterLabel, IsActive, SortOrder, DefaultProbability\n FROM\n OpportunityStage';\n }\n\n if ($missingStageName) {\n $escapedStageName = ValueNormalizer::replaceQueryWithStringLiterals($missingStageName);\n\n $query .= ' WHERE ApiName = :stageName';\n\n $params = [\n 'stageName' => $escapedStageName,\n ];\n }\n\n try {\n $sfStages = $this->queryHandler->query($query, $params);\n } catch (NoResultsException $exception) {\n $sfStages = [];\n }\n\n $missingStage = null;\n\n // Upsert all stages for the team.\n foreach ($sfStages as $sfStage) {\n $selectable = true;\n if (array_key_exists('IsActive', $sfStage)) {\n $selectable = $sfStage['IsActive'];\n }\n\n $this->restoreAnyTrashedEntity($this->config->stages(), $sfStage['Id']);\n\n $stage = $this->config->stages()->updateOrCreate([\n 'crm_provider_id' => $sfStage['Id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($sfStage['ApiName'], 0, 50),\n 'label' => mb_strimwidth($sfStage['MasterLabel'], 0, 191),\n 'type' => $type,\n 'sequence' => $sfStage['SortOrder'] ?? 0,\n 'is_selectable' => $selectable,\n 'probability' => $sfStage['DefaultProbability'] ?? null,\n ]);\n\n if ($missingStageName && $missingStageName === $sfStage['ApiName']) {\n $missingStage = $stage;\n }\n }\n\n if ($missingStageName && $missingStage === null) {\n // If they requested a stage that still doesn't exist, it must be inactive so lazy create it.\n $missingStage = $this->config->stages()->create([\n 'crm_provider_id' => Uuid::uuid4(),\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($missingStageName, 0, 50),\n 'label' => mb_strimwidth($missingStageName, 0, 191),\n 'type' => $type,\n 'sequence' => 0,\n 'is_selectable' => 0,\n ]);\n }\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int\n {\n $syncCount = 0;\n $fields = $this->getAllFieldsAsArray('lead');\n if (\\in_array('Id', $fields, true) === false) {\n return $syncCount;\n }\n\n $query = '\n SELECT ' . rtrim(implode(',', $fields), ',') . '\n FROM Lead\n WHERE LastModifiedDate > :since\n ORDER BY LastModifiedDate ASC';\n\n try {\n $sfLeads = $this->queryHandler->query($query, [\n 'since' => $since->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($sfLeads as $sfLead) {\n // Only sync if previously imported.\n if ($this->hasLead($sfLead['Id'])) {\n $this->importLead($sfLead);\n $syncCount++;\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::LEAD);\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLead(string $crmId): ?Lead\n {\n $fields = $this->getAllFieldsAsArray('lead');\n\n $sfLead = $this->getRecord('Lead', $crmId, $fields);\n\n return $this->importLead($sfLead);\n }\n\n private function importLead($crmData): ?Lead\n {\n /** @var ?Stage $stage */\n $stage = null;\n if (isset($crmData['Status'])) {\n // Get the current stage.\n $stage = $this->config\n ->stages()\n ->where('name', $crmData['Status'])\n ->where('type', Stage::TYPE_LEAD)\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_LEAD], $crmData['Status']);\n }\n }\n\n // If we have no way of importing this, just return null :(\n if ($stage === null) {\n return null;\n }\n\n $countryCode = $crmData['CountryCode'] ?? null;\n\n // Salesforce allows custom \"countries\" to be created. Disregard these.\n if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {\n $countryCode = null;\n }\n\n // If we have no country code, try to parse it from the country name.\n if ($countryCode === null && empty($crmData['Country']) !== false) {\n $countryCode = $this->convertCountryNameToCode($crmData['Country']);\n }\n\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($crmData['Phone'] ?? '', 0, 25);\n $parsedNumber = parsePhoneNumber($countryCode, $number);\n\n $mobilePhone = null;\n if (empty($crmData['MobilePhone']) === false) {\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($crmData['MobilePhone'], 0, 25);\n $mobilePhone = phone_e164($countryCode, $number);\n }\n\n $convertedDate = null;\n $convertedAccount = null;\n $convertedOpportunity = null;\n $convertedContact = null;\n\n if ($crmData['IsConverted'] == 'true') {\n $convertedDate = $crmData['ConvertedDate'];\n\n if (empty($crmData['ConvertedAccountId']) === false) {\n $convertedAccount = $this->config\n ->accounts()\n ->where('crm_provider_id', $crmData['ConvertedAccountId'])\n ->first();\n\n if ($convertedAccount === null) {\n try {\n $convertedAccount = $this->syncAccount($crmData['ConvertedAccountId']);\n } catch (HttpNotFoundException $exception) {\n // Probably the user has no permissions to access the converted data.\n }\n }\n }\n\n if (empty($crmData['ConvertedOpportunityId']) === false) {\n $convertedOpportunity = $this->config\n ->opportunities()\n ->where('crm_provider_id', $crmData['ConvertedOpportunityId'])\n ->first();\n\n if ($convertedOpportunity === null) {\n try {\n $convertedOpportunity = $this->syncOpportunity($crmData['ConvertedOpportunityId']);\n } catch (HttpNotFoundException $exception) {\n // Probably the user has no permissions to access the converted data.\n }\n }\n }\n\n if (empty($crmData['ConvertedContactId']) === false) {\n $convertedContact = $this->team\n ->crm\n ->contacts()\n ->where('crm_provider_id', $crmData['ConvertedContactId'])\n ->first();\n\n if ($convertedContact === null) {\n try {\n $convertedContact = $this->syncContact($crmData['ConvertedContactId']);\n } catch (HttpNotFoundException $exception) {\n // Probably the user has no permissions to access the converted data.\n }\n }\n }\n }\n\n if (empty($crmData['Company'])) {\n $company = 'Unknown';\n } else {\n $company = mb_strimwidth($crmData['Company'], 0, 191);\n }\n\n $domain = null;\n if (empty($crmData['Website']) === false) {\n $domain = mb_strimwidth($crmData['Website'], 0, 191);\n $domain = StringUtil::resolveDomain($domain);\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $data = [\n 'team_id' => $this->team->id,\n 'user_id' => $profile?->user_id,\n 'owner_id' => $crmData['OwnerId'] ?? '',\n 'company' => $company,\n 'domain' => $domain,\n 'name' => $crmData['Name'] ? mb_strimwidth($crmData['Name'], 0, 191) : '',\n 'title' => $crmData['Title'] ? mb_strimwidth($crmData['Title'], 0, 128) : null,\n 'email' => $crmData['Email'] ? mb_strimwidth($crmData['Email'], 0, 80) : null,\n 'phone' => $parsedNumber['phone'],\n 'ext' => $parsedNumber['ext'] ?? null,\n 'mobile_phone' => $mobilePhone,\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['Id'],\n modelType: Lead::class,\n fileName: $crmData['Id'],\n avatarText: $crmData['Name']\n ),\n 'stage_id' => $stage->id,\n 'record_type_id' => null,\n 'converted_at' => $convertedDate,\n 'converted_account_id' => $convertedAccount->id ?? null,\n 'converted_opportunity_id' => $convertedOpportunity->id ?? null,\n 'converted_contact_id' => $convertedContact->id ?? null,\n 'country_code' => $countryCode,\n 'remotely_created_at' => $createdDate,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->leads(), $crmData['Id']);\n\n /** @var Lead $lead */\n $lead = $this->config->leads()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n if ($lead->wasChanged('converted_at') && $lead->getConvertedAt() !== null) {\n $this->eventDispatcher->dispatch(new LeadConverted($lead));\n }\n\n $this->handleObjectDeletion($lead, $crmData);\n\n if ($lead->trashed()) {\n return null;\n }\n\n return $lead;\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccounts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n $fields = $this->getAllFieldsAsArray('account');\n\n if (\\in_array('Id', $fields, true) === false) {\n return $syncCount;\n }\n\n $query = '\n SELECT ' . rtrim(implode(',', $fields), ',') . '\n FROM Account\n WHERE LastModifiedDate > :since\n ORDER BY LastModifiedDate ASC';\n\n try {\n $sfAccounts = $this->queryHandler->query($query, [\n 'since' => $since->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($sfAccounts as $sfAccount) {\n // Only sync if previously imported.\n if ($this->hasAccount($sfAccount['Id'])) {\n $this->importAccount($sfAccount);\n $syncCount++;\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::ACCOUNT);\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccount(string $crmId): ?Account\n {\n $fields = $this->getAllFieldsAsArray('account');\n if (! in_array('Id', $fields, true)) {\n $this->logger->info('[Salesforce] Sync account cancelled. Fields are not available.', [\n 'crmId' => $crmId,\n 'userId' => $this->profile->getUserId(),\n ]);\n\n return null;\n }\n\n $sfAccount = $this->getRecord('Account', $crmId, $fields);\n\n return $this->importAccount($sfAccount);\n }\n\n private function importAccount($crmData): Account\n {\n $countryCode = $crmData['BillingCountryCode'] ?? $crmData['ShippingCountryCode'] ?? null;\n\n // Salesforce allows custom \"countries\" to be created. Disregard these.\n if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {\n $countryCode = null;\n }\n\n // If we have no country code, try to parse it from the country names.\n if ($countryCode === null && empty($crmData['BillingCountry']) === false) {\n $countryCode = $this->convertCountryNameToCode($crmData['BillingCountry']);\n }\n\n if ($countryCode === null && empty($crmData['ShippingCountry']) === false) {\n $countryCode = $this->convertCountryNameToCode($crmData['ShippingCountry']);\n }\n\n if (empty($crmData['Phone']) === false) {\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($crmData['Phone'], 0, 25);\n $parsedNumber = parsePhoneNumber($countryCode, $number);\n } else {\n $parsedNumber = [];\n }\n\n $industry = null;\n if (empty($crmData['Industry']) === false) {\n $industry = mb_strimwidth($crmData['Industry'], 0, 40);\n }\n\n $domain = null;\n if (empty($crmData['Website']) === false) {\n $domain = mb_strimwidth($crmData['Website'], 0, 191);\n $domain = StringUtil::resolveDomain($domain);\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $data = [\n 'team_id' => $this->team->id,\n 'user_id' => $profile?->user_id,\n 'owner_id' => $crmData['OwnerId'],\n 'name' => mb_strimwidth($crmData['Name'], 0, 191),\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['Id'],\n modelType: Account::class,\n fileName: $crmData['Id'],\n avatarText: $crmData['Name']\n ),\n 'industry' => $industry,\n 'domain' => $domain,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'ext' => $parsedNumber['ext'] ?? null,\n 'country_code' => $countryCode,\n 'remotely_created_at' => $createdDate,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->accounts(), $crmData['Id']);\n\n /** @var Account $account */\n $account = $this->config->accounts()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n $this->handleObjectDeletion($account, $crmData);\n\n return $account;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOpportunities(array $parameters, ?string $strategy = null): int\n {\n $strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);\n\n $syncCount = 0;\n $logParams = $parameters;\n $parameters['profile'] = $this->profile;\n $logParams['user'] = $this->profile->getUserId();\n\n if (count($strategies) > 1) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Multiple sync strategies used', [\n 'teamId' => $this->team->getUuid(),\n 'params' => $logParams,\n 'strategies_count' => count($strategies),\n ]);\n }\n\n foreach ($strategies as $syncStrategy) {\n $name = $syncStrategy->getStrategyName();\n\n try {\n $sfOpportunities = $syncStrategy->fetchOpportunities($parameters);\n $totalRecords = $sfOpportunities->count();\n\n foreach ($sfOpportunities as $sfOpportunity) {\n $this->importOpportunity($sfOpportunity);\n $syncCount++;\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n $this->logger->warning('[' . $this->getDisplayName() . '] No opportunities found', [\n 'teamId' => $this->team->getUuid(),\n 'name' => $name,\n 'params' => $logParams,\n 'reason' => $noResultsException->getMessage(),\n ]);\n } catch (CrmException $crmException) {\n // Nothing to sync.\n $this->logger->warning('[' . $this->getDisplayName() . '] Opportunity sync failed', [\n 'teamId' => $this->team->getUuid(),\n 'name' => $name,\n 'params' => $logParams,\n 'reason' => $crmException->getMessage(),\n ]);\n }\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::OPPORTUNITY, ['params' => $logParams]);\n\n // debug to see how if count of opportunities reaches 1000\n if ($syncCount >= 1000) {\n $this->logger->info(\n '[' . $this->getDisplayName() . '] Sync Opportunities - count warning',\n [\n 'team_id' => $this->team->getId(),\n 'params' => $logParams,\n 'count' => $syncCount,\n 'strategies_count' => count($strategies),\n 'total_records' => $totalRecords ?? null,\n ]\n );\n }\n\n return $syncCount;\n }\n\n\n /**\n * @inheritdoc\n */\n public function syncOpportunity(string $crmId): ?Opportunity\n {\n $strategy = $this->opportunitySyncStrategyResolver->resolve(\n $this->config,\n OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY\n );\n\n $parameters = [\n 'profile' => $this->profile,\n 'crm_id' => $crmId,\n ];\n\n try {\n $sfOpportunity = $strategy->fetchOpportunities($parameters);\n } catch (HttpNotFoundException $e) {\n $this->logger->info('[' . $this->getDisplayName() . '] Opportunity not found', [\n 'teamId' => $this->team->id_string,\n 'crmId' => $crmId,\n ]);\n\n return null;\n } catch (CrmException $crmException) {\n $this->logger->info('[' . $this->getDisplayName() . '] Opportunity sync failed', [\n 'teamId' => $this->team->id_string,\n 'crmId' => $crmId,\n 'exception' => $crmException->getMessage(),\n ]);\n\n return null;\n }\n\n if ($sfOpportunity instanceof ArrayIterator) {\n return $this->importOpportunity($sfOpportunity->getItems());\n }\n\n return $this->importOpportunity($sfOpportunity);\n }\n\n /**\n * @throws HttpNotFoundException\n */\n private function importOpportunity($crmData): ?Opportunity\n {\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $account = null;\n if (empty($crmData['AccountId']) === false) {\n /** @var ?Account $account */\n $account = $this->config->accounts()\n ->where('crm_provider_id', (string) $crmData['AccountId'])\n ->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmData['AccountId']);\n }\n }\n\n $userId = $profile?->getUserId() ?? $account?->getUserId();\n if ($userId === null) {\n $this->logger->error('[Salesforce] | Skip import, no user_id found', [\n 'id' => $crmData['Id'],\n ]);\n\n return null;\n }\n\n /** @var ?Stage $stage */\n $stage = null;\n if (isset($crmData['StageName'])) {\n $stage = $this->config\n ->stages()\n ->where('name', $crmData['StageName'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->orderBy('is_selectable', 'DESC')\n ->orderBy('id')\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $crmData['StageName']);\n }\n }\n\n $recordType = null;\n if (empty($crmData['RecordTypeId']) === false) {\n /** @var ?RecordType $recordType */\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $crmData['RecordTypeId'])\n ->first();\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $closeDate = null;\n if (empty($crmData['CloseDate']) === false) {\n $closeDate = Carbon::parse($crmData['CloseDate'])->format('Y-m-d');\n }\n\n $valueFieldName = 'Amount';\n if ($this->config->opportunity_value_field_id) {\n $valueFieldName = $this->config->opportunityValueField->crm_provider_id;\n }\n\n $data = [\n 'team_id' => $this->team->id,\n 'account_id' => $account->id ?? null,\n 'user_id' => $userId,\n 'owner_id' => $crmData['OwnerId'] ?? null,\n 'name' => mb_strimwidth($crmData['Name'] ?? '', 0, 128),\n 'value' => $crmData[$valueFieldName],\n 'currency_code' => CurrencyFormatter::formatCode($crmData['CurrencyIsoCode'] ?? null),\n 'close_date' => $closeDate,\n 'is_closed' => $crmData['IsClosed'],\n 'is_won' => $crmData['IsWon'],\n 'stage_id' => $stage?->id ?? null,\n 'record_type_id' => $recordType->id ?? null,\n 'remotely_created_at' => $createdDate,\n 'probability' => $crmData['Probability'] ?? null,\n 'forecast_category' => $crmData['ForecastCategoryName'] ?? null,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->opportunities(), $crmData['Id']);\n\n // Do not allow locked DB tables & other errors\n // to interrupt the process of reverting the trashed opportunities\n try {\n /** @var Opportunity $opportunity */\n $opportunity = $this->config->opportunities()\n ->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n // import external fields into crm_field_data if present\n $crmFields = $this->getOpportunitySyncableFields();\n\n $this->importOpportunityCrmFieldData($crmData, $crmFields, $opportunity->id);\n\n $this->handleObjectDeletion($opportunity, $crmData);\n\n if ($opportunity->trashed()) {\n return null;\n }\n\n if ($opportunity->wasRecentlyCreated) {\n MatchActivitiesToNewOpportunity::dispatch($opportunity->getId());\n }\n\n return $opportunity;\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n $this->logger->error('[Salesforce] importOpportunity failure.', [\n 'crm_provider_id' => $crmData['Id'],\n 'team_id' => $this->team->id,\n 'exception' => $exception->getMessage(),\n ]);\n\n $this->handleEntityDeletionByProviderId($this->config->opportunities(), $crmData);\n }\n\n return null;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContacts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n $fields = $this->getAllFieldsAsArray('contact');\n if (\\in_array('Id', $fields, true) === false) {\n return $syncCount;\n }\n\n $query = '\n SELECT ' . rtrim(implode(',', $fields), ',') . '\n FROM Contact\n WHERE LastModifiedDate > :since\n ORDER BY LastModifiedDate ASC';\n\n try {\n $sfContacts = $this->queryHandler->query($query, [\n 'since' => $since->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($sfContacts as $sfContact) {\n // Only sync if previously imported.\n if ($this->hasContact($sfContact['Id'])) {\n $this->importContact($sfContact);\n $syncCount++;\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::CONTACT);\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContact(string $crmId): ?Contact\n {\n $fields = $this->getAllFieldsAsArray('contact');\n if (! in_array('Id', $fields, true)) {\n $this->logger->info('[Salesforce] Sync contact cancelled. Fields are not available.', [\n 'crmId' => $crmId,\n 'userId' => $this->profile->getUserId(),\n ]);\n\n return null;\n }\n\n $sfContact = $this->getRecord('Contact', $crmId, $fields);\n\n return $this->importContact($sfContact);\n }\n\n private function importContact($crmData): ?Contact\n {\n $account = null;\n // Contacts may not have accounts...\n if (isset($crmData['AccountId'])) {\n $account = $this->config->accounts()\n ->where('crm_provider_id', (string) $crmData['AccountId'])\n ->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmData['AccountId']);\n }\n }\n\n $countryCode = $crmData['MailingCountryCode'] ?? null;\n\n // Salesforce allows custom \"countries\" to be created. Disregard these.\n if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {\n $countryCode = null;\n }\n\n // If we have no country code, try to parse it from the country name.\n if ($countryCode === null && empty($crmData['MailingCountry']) === false) {\n $countryCode = $this->convertCountryNameToCode($crmData['MailingCountry']);\n\n if ($countryCode === null && $account) {\n $countryCode = $account->country_code;\n }\n }\n\n $ext = null;\n $parsedNumber = [];\n if (empty($crmData['Phone']) === false) {\n $number = Str::limit($crmData['Phone'], 25, '');\n $parsedNumber = parsePhoneNumber($countryCode, $number);\n\n if (empty($parsedNumber['ext']) === false) {\n $ext = Str::limit($parsedNumber['ext'], 10, '');\n }\n }\n\n $mobileNumber = null;\n if (empty($crmData['MobilePhone']) === false) {\n $mobileNumber = Str::limit(phone_e164($countryCode, $crmData['MobilePhone']), 25, '');\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $data = [\n 'team_id' => $this->team->id,\n 'account_id' => $account->id ?? null,\n 'user_id' => $profile?->user_id,\n 'owner_id' => $crmData['OwnerId'] ?? null,\n 'name' => ($crmData['Name'] ?? null) !== null ? mb_strimwidth($crmData['Name'], 0, 100) : '',\n 'title' => ($crmData['Title'] ?? null) !== null ? mb_strimwidth($crmData['Title'], 0, 128) : null,\n 'email' => ($crmData['Email'] ?? null) !== null ? mb_strimwidth($crmData['Email'], 0, 191) : null,\n 'country_code' => $countryCode,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'ext' => $ext,\n 'mobile_phone' => $mobileNumber,\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['Id'],\n modelType: Contact::class,\n fileName: $crmData['Id'],\n avatarText: $crmData['Name']\n ),\n 'remotely_created_at' => $createdDate,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->contacts(), $crmData['Id']);\n\n /** @var Contact $contact */\n $contact = $this->config->contacts()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n $this->handleObjectDeletion($contact, $crmData);\n\n if ($contact->trashed()) {\n return null;\n }\n\n return $contact;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n $fields = [\n 'InstanceName',\n 'OrganizationType',\n 'IsSandbox',\n ];\n\n $orgValues = $this->getRecord('Organization', $this->config->crm_provider_id, $fields);\n\n $edition = null;\n switch ($orgValues['OrganizationType']) {\n case 'Developer Edition':\n $edition = Configuration::EDITION_DEVELOPER;\n\n break;\n\n case 'Professional Edition':\n $edition = Configuration::EDITION_PROFESSIONAL;\n\n break;\n\n case 'Enterprise Edition':\n $edition = Configuration::EDITION_ENTERPRISE;\n\n break;\n }\n\n $this->config->edition = $edition;\n $this->config->instance = $orgValues['InstanceName'];\n\n // XXX: How can this state be possible?\n if ($this->config->version === null) {\n $this->config->version = Client::MIN_API_VERSION;\n }\n\n $installedVersion = $this->getInstalledAppVersion();\n if ($installedVersion !== null) {\n $installedVersion = (string) $this->getInstalledAppVersion();\n }\n\n $this->config->installed_app_version = $installedVersion;\n\n $this->config->save();\n }\n\n public function getInstalledAppVersion(): ?string\n {\n try {\n $query = '\n SELECT\n SubscriberPackageVersion.MajorVersion,\n SubscriberPackageVersion.MinorVersion,\n SubscriberPackageVersion.PatchVersion,\n SubscriberPackageVersion.BuildNumber\n FROM\n InstalledSubscriberPackage\n WHERE\n SubscriberPackageId = :packageId\n ';\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'packageId' => self::INSTALLED_PACKAGE_ID,\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n // Grab version number.\n $version = $sfField['SubscriberPackageVersion']['MajorVersion'] .\n $sfField['SubscriberPackageVersion']['MinorVersion'] .\n $sfField['SubscriberPackageVersion']['PatchVersion'] .\n $sfField['SubscriberPackageVersion']['BuildNumber'];\n } catch (\\Exception) {\n $version = null;\n }\n\n return $version;\n }\n\n /**\n * Store transcripts as note.\n *\n * @throws \\Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // For SF we also check if Log Notes is enabled.\n if ($this->profile->log_notes === Profile::LOG_NOTE_NONE) {\n return;\n }\n\n if ($activity->opportunity_id && $activity->prospect === null) {\n return;\n }\n\n try {\n $transcriptionData = $this->generateTranscription($activity);\n\n $noteMaxLength = $this->profile->log_notes === Profile::LOG_NOTE_ENHANCED\n ? self::ENHANCED_NOTE_MAX_LENGTH\n : self::CLASSIC_NOTE_MAX_LENGTH;\n\n $title = 'Transcript for ';\n $title .= $activity->title ?? $activity->activity_title;\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $body = mb_strimwidth($transcriptionData, 0, $noteMaxLength);\n\n if ($activity->opportunity_id) {\n $objectId = $activity->opportunity->crm_provider_id;\n } else {\n $objectId = $activity->prospect->crm_provider_id;\n }\n\n $noteId = $this->saveNote($title, $body, $objectId);\n\n // Store crm logged id in transcription.\n $transcription = $activity->getTranscription();\n $transcription->crm_activity_id = $noteId;\n $transcription->save();\n } catch (\\Exception $e) {\n \\Sentry::captureException($e);\n }\n }\n\n public function saveNote(string $title, string $body, string $objectId, ?NoteObject $noteObject = null): ?string\n {\n $noteId = null;\n\n try {\n if ($this->profile->log_notes === Profile::LOG_NOTE_ENHANCED) {\n $noteId = $this->buildEnhancedNote($title, $body, $objectId);\n } else {\n $noteId = $this->buildClassicNote($title, $body, $objectId);\n }\n } catch (HttpNotFoundException $exception) {\n // The profile not having access to create Enhanced Notes. Set their preference to Classic.\n if ($this->profile->log_notes === Profile::LOG_NOTE_ENHANCED) {\n $this->profile->update([\n 'log_notes' => Profile::LOG_NOTE_CLASSIC,\n ]);\n }\n }\n\n return $noteId;\n }\n\n /**\n * This is using the \"Enhanced\" Notes feature, NOT the \"Notes & Attachments\" feature being deprecated.\n *\n * @url https://salesforce.stackexchange.com/questions/104408/how-can-i-create-an-account-note-or-contact-note-via-api-that-is-visible-in-sale\n */\n private function buildEnhancedNote(string $title, string $body, string $objectId): string\n {\n // Decode stored entities, escape HTML (without quoting), then convert line breaks for Salesforce formatting\n $decodedBody = html_entity_decode($body, ENT_QUOTES | ENT_HTML5);\n $sanitizedBody = htmlspecialchars($decodedBody, ENT_NOQUOTES, 'UTF-8', false);\n $content = nl2br($sanitizedBody, false);\n $note = [\n 'OwnerId' => $this->profile->crm_provider_id,\n 'Title' => $title,\n 'Content' => base64_encode($content),\n ];\n\n $noteId = $this->createRecord('ContentNote', $note);\n\n $link = [\n 'ContentDocumentId' => $noteId,\n 'LinkedEntityId' => $objectId,\n 'ShareType' => 'I',\n ];\n\n $this->createRecord('ContentDocumentLink', $link);\n\n return $noteId;\n }\n\n private function buildClassicNote(string $title, string $body, string $objectId): string\n {\n if (in_array($this->parseObjectType($objectId), [Field::OBJECT_TASK, Field::OBJECT_EVENT])) {\n $this->logger->info('[Salesforce] Summary not sent', [\n 'profile_id' => $this->profile->id,\n 'objectId' => $objectId,\n 'reason' => 'Classical Note does not support Task/Event relation',\n ]);\n\n return '';\n }\n\n $titleTrimmed = null;\n\n if (mb_strlen($title) > 80) {\n $titleTrimmed = substr($title, 0, 77) . '...';\n }\n $payload = [\n 'OwnerId' => $this->profile->crm_provider_id,\n 'IsPrivate' => false,\n 'Title' => $titleTrimmed ?? $title,\n 'Body' => $titleTrimmed ? $title . PHP_EOL . $body : $body,\n 'ParentId' => $objectId,\n ];\n\n return $this->createRecord('Note', $payload);\n }\n\n /**\n * @inheritdoc\n */\n public function find(string $name, array $scopes): array\n {\n if ($this->profile === null) {\n return [];\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $limitValues = ['limit' => $this->limit, 'offset' => $this->offset];\n $sosl = $queryBuilder->buildFindQuery($name, $scopes, $limitValues);\n\n $this->logger->info('[Salesforce] Find prospects', [\n 'profile_id' => $this->profile->id,\n 'sosl_query' => $sosl,\n 'search_string' => $name,\n 'scopes' => $scopes,\n ]);\n\n $data = Cache::remember($this->profile->id . $sosl, self::CACHE_TTL, function () use ($sosl) {\n $data = [];\n\n try {\n // Hit remote API.\n $objects = $this->queryHandler->search($sosl);\n\n // Build mapped list.\n foreach ($objects as $object) {\n $type = strtolower($object['attributes']['type']);\n\n $record = [\n 'crmId' => $object['Id'],\n 'name' => $object['Name'],\n 'prospectType' => $type,\n 'phoneNumbers' => [],\n 'crmUrl' => $this->generateProviderUrl($object['Id'], $type),\n ];\n\n switch ($type) {\n case 'lead':\n if (empty($object['Company']) === false) {\n $record['organization'] = $object['Company'];\n }\n\n if (empty($object['Title']) === false) {\n $record['title'] = $object['Title'];\n }\n\n $stage = $this->config->stages()\n ->where('type', Stage::TYPE_LEAD)\n ->where('name', $object['Status'])\n ->first();\n\n // Lazy create the stage.\n if ($stage === null) {\n $stage = $this->importStages([Stage::TYPE_LEAD], $object['Status']);\n }\n\n if ($stage) {\n $record += [\n 'stage' => [\n 'id' => $stage->id_string,\n 'name' => $stage->name,\n ],\n ];\n }\n\n if (empty($object['RecordTypeId']) === false) {\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $object['RecordTypeId'])\n ->first();\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n }\n\n break;\n\n case 'account':\n if (empty($object['Industry']) === false) {\n $record['industry'] = $object['Industry'];\n $record['detailsLine'] = $object['Industry'];\n }\n if (! empty($object['PersonEmail'])) {\n $record['detailsLine'] = $object['PersonEmail'];\n }\n\n break;\n\n case 'contact':\n // For contacts, we should try and fetch their account name too.\n if ($object['AccountId']) {\n // Cheaper to get this locally.\n $account = $this->config->accounts()\n ->where('crm_provider_id', $object['AccountId'])\n ->first(['name']);\n\n if ($account) {\n $record['organization'] = $account->name;\n }\n }\n\n if (! empty($object['IsPersonAccount']) && $object['Email']) {\n $record['detailsLine'] = $object['Email'];\n } else {\n if (empty($object['Title']) === false) {\n $record['title'] = $object['Title'];\n }\n }\n\n break;\n }\n\n // Add phone numbers to record.\n if (empty($object['Phone']) === false && $object['Phone']) {\n $record['phoneNumbers'][] = [\n 'number' => $object['Phone'],\n 'nationalFormat' => phone_national($this->profile->user->country_code, $object['Phone']),\n 'type' => 'phone',\n ];\n }\n\n if (empty($object['MobilePhone']) === false && $object['MobilePhone']) {\n $record['phoneNumbers'][] = [\n 'number' => $object['MobilePhone'],\n 'nationalFormat' => phone_national(\n $this->profile->user->country_code,\n $object['MobilePhone']\n ),\n 'type' => 'mobile',\n ];\n }\n\n $data[] = $record;\n }\n } catch (NoResultsException $e) {\n $data = [];\n }\n\n return $data;\n });\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n\n if ($crmAccountId === null) {\n return $data;\n }\n\n if ($userId) {\n $profileRepository = app(ProfileRepository::class);\n $profile = $profileRepository->findProfileByUserId($this->config, $userId);\n\n $ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;\n }\n\n try {\n // Perhaps their profile has no opportunity permissions.\n if ($this->profile === null || $this->profile->opportunity_fields === null) {\n return $data;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $query = $queryBuilder->buildFindOpportunitiesQuery();\n\n $objects = $this->queryHandler->query($query, ['accountId' => $crmAccountId]);\n\n foreach ($objects as $object) {\n $record = [\n 'crmId' => $object['Id'],\n 'name' => $object['Name'],\n 'won' => $object['IsWon'],\n 'closed' => $object['IsClosed'],\n ];\n\n $valueFieldName = 'Amount';\n if ($this->config->opportunity_value_field_id) {\n $valueFieldName = $this->config->opportunityValueField->crm_provider_id;\n }\n\n if (empty($object[$valueFieldName]) === false) {\n $currency = $object['CurrencyIsoCode'] ?? $this->config->default_currency;\n $value = formatCurrency($object[$valueFieldName], $currency);\n\n $record += [\n 'value' => $value,\n ];\n }\n\n $stage = $this->config->stages()\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->where('name', $object['StageName'])\n ->first();\n\n // Lazy create the stage.\n if ($stage === null) {\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $object['StageName']);\n }\n\n $record += [\n 'stage' => [\n 'id' => $stage->id_string,\n 'name' => $stage->name,\n ],\n ];\n\n if (empty($object['RecordTypeId']) === false) {\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $object['RecordTypeId'])\n ->first();\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n }\n\n if ($ownerId && isset($object['OwnerId']) && $object['OwnerId'] === $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n } catch (NoResultsException $e) {\n return $data;\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n public function getContactRolesFromCrm(?Carbon $since = null): array\n {\n $roles = [];\n\n if ($this->profile === null) {\n return $roles;\n }\n\n $queryBuilder = app(QueryBuilder::class, ['profile' => $this->profile]);\n\n $query = $queryBuilder->buildGetContactRolesQuery($since);\n\n try {\n $objects = $this->queryHandler->query($query);\n\n foreach ($objects as $object) {\n $roles[] = [\n 'id' => $object['Id'],\n 'contactId' => $object['ContactId'],\n 'opportunityId' => $object['OpportunityId'],\n 'ownerId' => $object['Opportunity']['OwnerId'] ?? null,\n 'isPrimary' => $object['IsPrimary'],\n 'role' => $object['Role'],\n ];\n }\n } catch (NoResultsException) {\n // Just return an empty array.\n $this->logger->info('[Salesforce] No contact roles found', [\n 'since' => $since?->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n }\n\n return $roles;\n }\n\n public function syncContactRoles(Carbon $since): int\n {\n $contactRoleRepository = app(ContactRoleRepository::class);\n\n $crmContactRoles = $this->getContactRolesFromCrm(since: $since);\n $syncCount = 0;\n $contactRoles = [];\n\n foreach ($crmContactRoles as $crmContactRole) {\n $contactRoles[] = $this->importContactRole($crmContactRole);\n $syncCount++;\n }\n\n $contactRoleRepository->saveContactRoles($contactRoles);\n\n $this->syncRemotelyDeletedContactRoles();\n\n return $syncCount;\n }\n\n private function importContactRole(array $contactRole): array\n {\n $contact = $this->config->contacts()\n ->where('crm_provider_id', $contactRole['contactId'])\n ->first();\n\n if ($contact === null) {\n $contact = $this->syncContact($contactRole['contactId']);\n }\n\n $opportunity = $this->config->opportunities()\n ->where('crm_provider_id', $contactRole['opportunityId'])\n ->first();\n\n if ($opportunity === null) {\n $opportunity = $this->syncOpportunity($contactRole['opportunityId']);\n }\n\n $role = null;\n if (! empty($contactRole['role'])) {\n $role = mb_strimwidth($contactRole['role'], 0, 191);\n }\n\n return [\n 'crm_configuration_id' => $this->config->getId(),\n 'contact_id' => $contact->getId(),\n 'crm_provider_id' => $contactRole['id'],\n 'subject_type' => ContactRole::SUBJECT_TYPE_OPPORTUNITY,\n 'subject_id' => $opportunity->getId(),\n 'is_primary' => $contactRole['isPrimary'],\n 'role' => $role,\n ];\n }\n\n protected function syncRemotelyDeletedContactRoles(): bool\n {\n try {\n $deletedRemotely = $this->queryHandler->queryDeleted('OpportunityContactRole');\n } catch (NoResultsException $e) {\n return false;\n }\n\n $deletedOpportunities = $deletedRemotely->getResults();\n $deletedIds = array_column($deletedOpportunities, 'id');\n\n $contactRoleRepository = app(ContactRoleRepository::class);\n\n foreach (array_chunk($deletedIds, self::HARD_DELETE_CHUNK) as $chunk) {\n $contactRoleRepository->deleteContactRoles($chunk);\n\n $this->logger->info('[' . $this->getDisplayName() . '] Remotely deleted opportunities synced', [\n 'teamId' => $this->team->id_string,\n 'remotelyDeletedOpportunities' => $chunk,\n 'count' => count($chunk),\n ]);\n }\n\n return true;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = $objects = [];\n\n $hasWho = \\in_array($objectType, ['lead', 'contact']);\n $playbook = $this->getPlaybook($this->profile->user);\n $fields = array_merge(\n $this->profile->getFieldsAsArray(parent::OBJECT_TASK),\n $playbook && $playbook->activityField ? [$playbook->activityField->crm_provider_id] : []\n );\n\n // Query should default to any open call for that user.\n $query = '\n SELECT ' . implode(',', array_unique($fields)) . '\n FROM Task\n WHERE OwnerId = :ownerId\n AND IsArchived = false\n AND IsDeleted = false\n AND IsClosed = false\n AND (';\n\n if ($objectType === 'account') {\n // This covers tasks tied to a related contact or opportunity too.\n $query .= '\n AccountId = :accountId';\n }\n\n if ($hasWho) {\n $query .= '\n WhoId = :whoId';\n\n // If we are also going to check on a specific opportunity, set that up.\n if ($opportunityId) {\n $query .= ' OR WhatId = :whatId';\n }\n }\n\n $query .= ' ) ORDER BY LastModifiedDate DESC';\n\n try {\n $objects = $this->queryHandler->query($query, [\n 'ownerId' => $this->profile->crm_provider_id,\n 'whoId' => $objectId,\n 'whatId' => $opportunityId,\n 'accountId' => $objectId,\n ]);\n } catch (NoResultsException $e) {\n return $data;\n } finally {\n $this->logger->debug(sprintf('[Salesforce] Found %s tasks for query \"%s\"', count($objects), $query));\n }\n\n foreach ($objects as $object) {\n $dueDate = $object['ActivityDate'] ? Carbon::parse($object['ActivityDate'])->toIso8601String() : null;\n $data[] = [\n 'crmId' => $object['Id'],\n 'subject' => $object['Subject'],\n 'due' => $dueDate,\n 'type' => $object[$playbook->activityField->crm_provider_id],\n ];\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getEvents(string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = $objects = [];\n $user = $this->profile?->user;\n if ($this->profile === null || $user === null) {\n return $data;\n }\n\n $hasWho = \\in_array($objectType, ['lead', 'contact']);\n $playbook = $this->getPlaybook($user);\n $fields = array_merge(\n $this->profile->getFieldsAsArray(parent::OBJECT_EVENT),\n $playbook && $playbook->activityField ? [$playbook->activityField->crm_provider_id] : []\n );\n\n // Query should default to any event starting in the last week and ending up until today owned by the user.\n $query = '\n SELECT ' . implode(',', array_unique($fields)) . '\n FROM Event\n WHERE OwnerId = :ownerId\n AND IsArchived = false\n AND IsAllDayEvent = false\n AND StartDateTime >= LAST_N_DAYS:7\n AND EndDateTime <= TODAY\n AND (';\n\n if ($objectType === 'account') {\n // This covers events tied to a related contact or opportunity too.\n $query .= '\n AccountId = :accountId';\n }\n\n if ($hasWho) {\n $query .= '\n WhoId = :whoId';\n\n // If we are also going to check on a specific opportunity, set that up.\n if ($opportunityId) {\n $query .= ' OR WhatId = :whatId';\n }\n }\n\n $query .= ' ) ORDER BY LastModifiedDate DESC';\n\n try {\n $objects = $this->queryHandler->query($query, [\n 'ownerId' => $this->profile->crm_provider_id,\n 'whoId' => $objectId,\n 'whatId' => $opportunityId,\n 'accountId' => $objectId,\n ]);\n } catch (NoResultsException $e) {\n return $data;\n } finally {\n $this->logger->debug(sprintf('[Salesforce] Found %s tasks for query \"%s\"', count($objects), $query));\n }\n\n foreach ($objects as $object) {\n $dueDate = $object['StartDateTime'] ? Carbon::parse($object['StartDateTime'])->toIso8601String() : null;\n\n $data[] = [\n 'crmId' => $object['Id'],\n 'subject' => $object['Subject'],\n 'due' => $dueDate,\n 'type' => $object[$playbook->activityField->crm_provider_id],\n ];\n }\n\n return $data;\n }\n\n /**\n * Try to find CRM Objects using email address\n *\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchExactlyByEmail(string $email, ?int $userId = null): ?array\n {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $sosl = $queryBuilder->buildMatchByQuery($email, Field::TYPE_EMAIL);\n if ($sosl === null) {\n return null;\n }\n\n try {\n $objects = $this->queryHandler->search($sosl);\n $objects = $this->queryHandler->prioritiseResults(\n $objects,\n $email,\n QueryHandler::PRIORITISE_EMAIL\n );\n\n $data = $this->convertCrmData($objects, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (NoResultsException $e) {\n // Try the account next.\n if ($this->profile->account_fields === null) {\n return null;\n }\n }\n\n return null;\n }\n\n public function getDomain(string $email): ?string\n {\n // SF improved search - strip the domain extension, min domain name length 4\n return $this->getCompanyNameFromEmail(email: $email, minNameLength: 4);\n }\n\n /**\n * Try to find CRM objects using domain name of the email address\n *\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByDomain(string $domain, ?int $userId = null): ?array\n {\n $companyName = $domain;\n\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $sosl = $queryBuilder->buildMatchByDomainQuery($companyName);\n\n try {\n $objects = $this->queryHandler->search($sosl);\n\n $data = $this->convertCrmData($objects, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (NoResultsException) {\n return null;\n }\n }\n\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n // Don't bother looking up numbers that are masked.\n if (str_contains($phone, '**')) {\n return null;\n }\n\n if ($this->isPhoneNumberOfTeamMember($phone)) {\n return null;\n }\n\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $phoneNational = phone_national(null, $phone) ?? '';\n $possiblePhoneFormats = collect([\n preg_replace('/\\D/', '', ltrim($phone, '0+')),\n preg_replace('/\\D/', '', $phoneNational),\n formatDashPhoneNumber($phone),\n $phoneNational,\n ])\n ->filter() // Removes null and empty strings\n ->unique()\n ->values();\n\n foreach ($possiblePhoneFormats as $phone) {\n $sosl = $queryBuilder->buildMatchByQuery($phone, Field::TYPE_PHONE);\n if ($sosl === null) {\n continue;\n }\n\n try {\n $objects = $this->queryHandler->search($sosl);\n $objects = $this->queryHandler->prioritiseResults(\n $objects,\n $phone,\n QueryHandler::PRIORITISE_PHONE\n );\n\n return $this->convertCrmData($objects, $userId);\n } catch (NoResultsException) {\n continue;\n }\n }\n\n return null;\n }\n\n private function isPhoneNumberOfTeamMember(string $phone): bool\n {\n $teamRepository = app(TeamRepository::class);\n $user = $teamRepository->findTeamMemberByPhone($this->team, $phone);\n\n if ($user instanceof User) {\n return true;\n }\n\n return false;\n }\n\n protected function getCacheKey(string $object, ?int $userId = null): ?string\n {\n $key = $this->profile->id . $object;\n $keySuffix = $this->getOwnerKeySuffix($userId);\n\n return $key . $keySuffix;\n }\n\n private function getOwnerKeySuffix(?int $userId = null): string\n {\n return $userId === null ? '' : (string) $userId;\n }\n\n /** Determine the CRM Objects which represent the call activity. */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n if ($this->profile === null) {\n return null;\n }\n\n $cacheKey = $this->getCacheKey($name, $userId);\n\n $result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $sosl = $queryBuilder->buildMatchByQuery($name, 'name');\n if ($sosl === null) {\n return false;\n }\n\n try {\n $objects = $this->queryHandler->search($sosl);\n } catch (NoResultsException $e) {\n return false;\n }\n\n $objects = $this->queryHandler->prioritiseResults(\n $objects,\n $name,\n QueryHandler::PRIORITISE_NAME\n );\n\n $data = $this->convertCrmData($objects, $userId);\n\n return (! empty(array_filter($data))) ? $data : false;\n });\n\n return is_array($result) ? $result : null;\n }\n\n /**\n * @return array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n protected function convertCrmData(QueryIterator $objects, ?int $userId = null): array\n {\n $lead = null;\n $contact = null;\n $opportunity = null;\n $account = null;\n $stage = null;\n $countryCode = null;\n\n if ($objects->count() > 0) {\n $object = $objects->current();\n\n if ($object['attributes']['type'] === 'Lead') {\n $lead = $this->importLead($object);\n\n // Lead might not be imported if the Stage is null for example.\n if ($lead) {\n $countryCode = $lead->country_code;\n $stage = $lead->stage;\n }\n } else {\n if ($object['attributes']['type'] === 'Contact') {\n $contact = $this->importContact($object);\n $account = $contact->account;\n } else {\n $account = $this->importAccount($object);\n }\n\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account) {\n $countryCode = $account->country_code;\n }\n\n try {\n $sfOpportunities = $this->findOpportunities(\n $account?->getCrmProviderId(),\n $contact?->getCrmProviderId(),\n $userId\n );\n\n // Take the first opportunity, which will be ordered as priority based on their settings.\n if (! empty($sfOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($sfOpportunities[0]['crmId']);\n $stage = $opportunity?->stage;\n }\n } catch (Exception) {\n // Nothing to see here.\n }\n }\n }\n\n return [\n $lead,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n /**\n * @inheritdoc\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n if ($stage->type === Stage::TYPE_LEAD) {\n $objectType = 'Lead';\n $objectId = $crmObject->crm_provider_id;\n $objectStageType = 'Status';\n } else {\n $objectType = 'Opportunity';\n $objectId = $crmObject->crm_provider_id;\n $objectStageType = 'StageName';\n }\n\n $headers = [];\n if ($this->config->trigger_assignment_rules === false) {\n // @see: https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/headers_autoassign.htm\n $headers = [\n 'Sforce-Auto-Assign' => 'false',\n ];\n }\n\n $this->updateRecord($objectType, $objectId, [$objectStageType => $stage->name], $headers);\n }\n\n public function parseObjectType(string $objectId): string\n {\n if (Str::startsWith($objectId, '001')) {\n return 'account';\n }\n\n if (Str::startsWith($objectId, '003')) {\n return 'contact';\n }\n\n if (Str::startsWith($objectId, '00Q')) {\n return 'lead';\n }\n\n if (Str::startsWith($objectId, '006')) {\n return 'opportunity';\n }\n\n if (Str::startsWith($objectId, '00U')) {\n return 'event';\n }\n\n if (Str::startsWith($objectId, '00T')) {\n return 'task';\n }\n\n throw new \\InvalidArgumentException('Unsupported Object Type');\n }\n\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, ['profile' => $this->profile]);\n $query = $queryBuilder->buildGetUsersQuery($userToSearch);\n\n try {\n $salesforceUsers = $this->queryHandler->query($query, [\n 'active' => true,\n ]);\n } catch (NoResultsException $e) {\n $this->logger->info('[Salesforce] Sync Profiles. No users found', [\n 'query' => $query,\n 'error' => $e->getMessage(),\n ]);\n\n return null;\n }\n\n $teamRepository = app(TeamRepository::class);\n $customRules = $this->getCustomProfileRules($teamRepository);\n\n foreach ($salesforceUsers as $crmUser) {\n if ($crmUser['Email'] === null) {\n continue;\n }\n\n if (! $this->customProfileValidation($crmUser, $customRules)) {\n continue;\n }\n\n $user = $teamRepository->findActiveTeamMemberByEmail($this->team, $crmUser['Email']);\n\n if (! $user instanceof User) {\n continue;\n }\n\n $edition = $crmUser['UserPreferencesLightningExperiencePreferred']\n ? Profile::EDITION_LIGHTNING\n : Profile::EDITION_CLASSIC;\n\n $profileRepository = app(ProfileRepository::class);\n $profile = $profileRepository->updateOrCreateProfile(\n $user,\n [\n 'crm_configuration_id' => $this->config->getId(),\n 'crm_provider_id' => $crmUser['Id'],\n ],\n [\n 'user_id' => $user->getId(),\n 'edition' => $edition,\n 'has_external_cti' => ! empty($crmUser['CallCenterId']),\n 'crm_profile_id' => $crmUser['ProfileId'],\n ]\n );\n\n if ($userToSearch instanceof User && $userToSearch->getId() === $user->getId()) {\n return $profile;\n }\n }\n\n // Clean up inactive profiles\n try {\n $this->archiveInactiveProfiles();\n } catch (\\Exception $e) {\n $this->logger->warning('[Salesforce] Profile archiving failed', [\n 'teamId' => $this->team->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $url = null;\n\n // For Salesforce it's easy, we just point every object to the apex domain and they handle it.\n switch ($objectType) {\n case 'lead':\n case 'account':\n case 'contact':\n case 'opportunity':\n case 'task':\n case 'event':\n case 'activity':\n\n $url = $this->config->crm_base_url . '/' . $providerId;\n\n break;\n }\n\n return $url;\n }\n\n public function buildTaskSearchFields(): array\n {\n return ['Id', 'WhoId', 'WhatId', 'AccountId'];\n }\n\n public function getTaskByFilterConditions(\n array $fields,\n array $filters,\n bool $bulkSearch = false,\n bool $strictFilters = true\n ): ?array {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $query = $queryBuilder->buildSearchTaskQuery($fields, $filters, $bulkSearch, $strictFilters);\n\n try {\n if (! $bulkSearch) {\n $objects = $this->queryHandler->query($query, $filters);\n if ($objects->count() === 1) {\n return $objects->current();\n }\n }\n\n if ($bulkSearch) {\n $objects = $this->queryHandler->query($query);\n $records = [];\n foreach ($objects as $record) {\n $key = $record[end($fields)];\n $records[$key] = $record;\n }\n\n return $records;\n }\n } catch (\\Exception $e) {\n $this->logger->info('[Salesforce] Failed to execute query', [\n 'query' => $query,\n 'error' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function mapCrmObjects(array $task): array\n {\n $activityData = [];\n\n if (! empty($task['WhoId'])) {\n $type = $this->parseObjectType($task['WhoId']);\n $activityData[$type] = $task['WhoId'];\n }\n if (! empty($task['AccountId'])) {\n $activityData['account'] = $task['AccountId'];\n }\n if (! empty($task['WhatId'])) {\n $activityData['opportunity'] = $task['WhatId'];\n }\n\n return $activityData;\n }\n\n /**\n * Get SF task by Outreach call id.\n */\n public function getTaskByFilter(\n string $activityFieldType,\n array $filters,\n string $operator = '=',\n array $additionalFields = []\n ): ?array {\n $data = [];\n\n try {\n // Default (base) fields.\n $fields = ['Id', 'Subject', 'Description', 'ActivityDate', 'WhoId', 'WhatId', $activityFieldType];\n\n foreach ($additionalFields as $additionalField) {\n $fields[] = $additionalField->crm_provider_id;\n }\n\n $fields = array_unique($fields);\n\n // Find task with the same Outreach id as the call id.\n $query = 'SELECT ' . implode(',', $fields) . '\n FROM Task\n WHERE IsArchived = false AND IsDeleted = false';\n\n foreach ($filters as $key => $value) {\n $key = preg_quote($key, '/');\n $key = str_replace(['\\'', '\"'], '', $key);\n // Prepare the substitution.\n $strKey = \":$key\";\n\n $query .= \" AND $key $operator $strKey\";\n }\n\n $query .= ' ORDER BY LastModifiedDate DESC LIMIT 1';\n\n $objects = $this->queryHandler->query($query, $filters);\n\n // There should be only one task related to this call if any.\n if ($objects->count() === 1) {\n $object = $objects->current();\n\n $dueDate = $object['ActivityDate'] ? Carbon::parse($object['ActivityDate'])->toIso8601String() : null;\n\n $data = array_merge($object, [\n 'crmId' => $object['Id'],\n 'subject' => $object['Subject'],\n 'summary' => $object['Description'],\n 'due' => $dueDate,\n 'Type' => $object[$activityFieldType],\n ]);\n }\n } catch (NoResultsException $e) {\n // Filters don't match any records.\n } catch (ServiceUnavailableException $serviceUnavailableException) {\n // Service cannot be queried. We should probably log this.\n }\n\n return $data;\n }\n\n /**\n * Get Salesforce fields including datetime fields\n *\n * @param $objectType\n */\n private function getAllFieldsAsArray($objectType): array\n {\n $basicFields = [];\n // Not all users have access to all object fields.\n if ($this->profile->{$objectType . '_fields'}) {\n $basicFields = explode(',', $this->profile->{$objectType . '_fields'});\n }\n\n $extraFields = [\n 'CreatedDate',\n 'LastModifiedDate',\n 'IsDeleted',\n ];\n\n if ($objectType === self::OBJECT_OPPORTUNITY\n && $this->config->opportunity_value_field_id\n && ! in_array($this->config->opportunityValueField->crm_provider_id, $basicFields)\n ) {\n $extraFields[] = $this->config->opportunityValueField->crm_provider_id;\n }\n\n return array_unique(array_merge($basicFields, $extraFields));\n }\n\n /**\n * Generate transcription for activity description.\n */\n private function generateTranscription(Activity $activity): string\n {\n if (! ($this->config->store_transcript)) {\n // If sending transcription to activity toggle is disabled\n return '';\n }\n\n return $this->transcriptionService\n ->findTranscriptionByActivity($activity)\n ->map(static function (array $transcriptionSegment): string {\n return $transcriptionSegment['formattedStartsAt'] . ' | ' . $transcriptionSegment['transcript'];\n })\n ->implode(PHP_EOL);\n }\n\n /**\n * Find related Salesforce event based on activity data\n *\n * @return array<string>\n */\n public function fetchRelatedActivity(Activity $activity): array\n {\n $this->logger->info('[Salesforce] Searching for related activity', [\n 'activityId' => $activity->getUuid(),\n 'ownerId' => $this->profile?->crm_provider_id,\n ]);\n\n $sfEvent = $this->fetchRelatedEvent($activity);\n if (empty($sfEvent)) {\n $this->logger->info('[Salesforce] No related activity found', [\n 'activityId' => $activity->getUuid(),\n 'ownerId' => $this->profile?->crm_provider_id,\n 'account' => $activity->hasAccount()\n ? $activity->getAccount()->getCrmProviderId()\n : null,\n ]);\n\n return [];\n }\n\n return $sfEvent;\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n if ($activity->isTypeConference() === false) {\n return null;\n }\n\n if ($activity->hasActualStartTime() === false && $activity->hasScheduledStartTime() === false) {\n return null;\n }\n\n if (! $activity->hasProspect()) {\n $this->logger->info('[Salesforce] Skip look up, Activity not linked to Lead, Contact or Account', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n $playbook = $this->getPlaybook($activity->getUser());\n if ($playbook !== null && $playbook->getActivityType() === Playbook::ACTIVITY_TYPE_TASK) {\n $this->logger->info('[Salesforce] Skip auto-sync for task-based playbook', [\n 'activityUuid' => $activity->getUuid(),\n 'playbookId' => $playbook->getId(),\n 'playbookType' => $playbook->getActivityType(),\n ]);\n\n return null;\n }\n\n try {\n $sfEvent = $this->fetchRelatedActivity($activity);\n if (empty($sfEvent)) {\n return null;\n }\n\n [$activityField, $activityType] = $this->resolveActivityTypeFromEvent($activity, $sfEvent);\n\n $this->logger->info('[Salesforce] Found related activity', [\n 'activityId' => $activity->getUuid(),\n 'sfEvent' => $sfEvent['Id'],\n 'activityFieldName' => $activityField,\n 'crmActivityType' => ($activityField !== null && isset($sfEvent[$activityField]))\n ? $sfEvent[$activityField]\n : null,\n 'activityType' => $activityType,\n ]);\n\n $userId = $this->findRelatedActivityUserId($activity, $sfEvent);\n\n if ($activity->getUserId() !== $userId) {\n $this->logger->info('[Salesforce] Updating meeting owner', [\n 'activityId' => $activity->getUuid(),\n 'oldUserId' => $activity->getUserId(),\n 'newUserId' => $userId,\n ]);\n }\n\n $this->updateSfEventDescription($activity, $sfEvent);\n\n $activity->update([\n 'user_id' => $userId,\n 'crm_provider_id' => $sfEvent['Id'],\n 'playbook_category_id' => $activityType->id ?? $activity->getCategory()?->getId(),\n ]);\n\n $this->logger->info('[Salesforce] Activity updated', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return $activity;\n } catch (\\Exception $exception) {\n \\Sentry::captureException($exception);\n\n throw $exception;\n }\n }\n\n /**\n * @param array<string, mixed> $sfEvent\n *\n * @return array{0: string|null, 1: mixed}\n */\n private function resolveActivityTypeFromEvent(Activity $activity, array $sfEvent): array\n {\n $activityField = $this->getActivityFieldName($activity);\n $activityType = null;\n\n if ($activityField !== null && ! empty($sfEvent[$activityField])) {\n $playbook = $this->getPlaybook($activity->getUser());\n $activityType = $this->getPlaybookCategory($playbook, strval($sfEvent[$activityField]));\n }\n\n return [$activityField, $activityType];\n }\n\n /**\n * @param array<string> $sfEvent\n */\n private function findRelatedActivityUserId(Activity $activity, array $sfEvent): int\n {\n $userId = $activity->getUserId();\n\n if (empty($sfEvent['OwnerId']) === false) {\n $profile = $this\n ->config\n ->profiles()\n ->where('crm_provider_id', $sfEvent['OwnerId'])\n ->get()\n ->filter(static function (Profile $profile) use ($activity): bool {\n if (! $activity->isTypeConference()) {\n return ! empty($profile->user) ? $profile->user->isStatusActive() : false;\n }\n\n $participants = $activity->getParticipants();\n\n return ! empty($profile->user)\n ? $profile->user->isStatusActive()\n && $profile->user->hasPermission(PermissionEnum::RECORD_MEETING)\n && $participants->contains('user_id', $profile->user_id)\n : false;\n })\n ->first();\n\n if ($profile) {\n $userId = $profile->user_id;\n }\n }\n\n return $userId;\n }\n\n /**\n * @param array<string, mixed> $sfEvent\n */\n private function updateSfEventDescription(Activity $activity, array $sfEvent): void\n {\n try {\n if (str_contains($sfEvent['Description'], $activity->id_string)) {\n return;\n }\n\n $payload = [\n 'Description' => $sfEvent['Description']\n . PHP_EOL\n . PHP_EOL\n . (new DecorateActivity())->generateDescription($activity),\n ];\n\n $this->logger->info('[Salesforce] Update record', [\n 'activityId' => $activity->getUuid(),\n 'sfEvent' => $sfEvent['Id'],\n 'payload' => $payload,\n ]);\n\n $payload = array_merge(\n $payload,\n $this->payloadBuilder->fetchCustomFieldData($activity, Field::OBJECT_EVENT)\n );\n\n $this->updateRecord('Event', $sfEvent['Id'], $payload);\n } catch (\\Exception) {\n $this->logger->error('[Salesforce] Failed to update record', [\n 'activityUuid' => $activity->getUuid(),\n 'sfEvent' => $sfEvent['Id'],\n ]);\n }\n }\n\n /**\n * Returns the most recently modified Event within time range (if any).\n *\n * @return array|null An Event record from Salesforce.\n */\n private function fetchRelatedEvent(Activity $activity): ?array\n {\n $ownerId = $this->profile?->crm_provider_id;\n if ($ownerId === null) {\n return [];\n }\n\n /** @var ?Carbon $from */\n /** @var ?Carbon $to */\n [$from, $to] = $this->getFromToDates($activity);\n\n try {\n $whoId = null;\n $hasWho = $activity->lead_id || $activity->contact_id;\n if ($hasWho) {\n $whoId = $activity->hasLead()\n ? $activity->getLead()->crm_provider_id\n : $activity->getContact()->crm_provider_id;\n }\n\n if ($hasWho === false && $activity->account_id === null) {\n return null;\n }\n\n $query = $this->buildFetchRelatedEventQuery($activity);\n\n $objects = $this->queryHandler->query($query, [\n 'ownerId' => $ownerId,\n 'whoId' => $whoId,\n 'whatId' => $activity->hasOpportunity() ? $activity->getOpportunity()->crm_provider_id : null,\n 'accountId' => $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null,\n 'from' => $from?->format('Y-m-d\\TH:i:s\\Z'),\n 'to' => $to?->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($objects as $object) {\n return $object;\n }\n } catch (NoResultsException $e) {\n return [];\n }\n\n return [];\n }\n\n private function getFromToDates(Activity $activity): array\n {\n $from = null;\n $to = null;\n\n /** @var ?CalendarEvent $calendarEvent */\n $calendarEvent = $activity->calendarEvent()->first();\n if ($calendarEvent !== null) {\n $from = $calendarEvent->getStartTime();\n $to = $calendarEvent->getEndTime();\n }\n\n // For non-calendar imported activities\n // Also double check if calendar event dates could be null?\n // If null use what we've got so far\n if ($from === null || $to === null) {\n $from = $activity->hasScheduledStartTime()\n ? $activity->getScheduledStartTime()\n : $activity->getActualStartTime();\n $to = $activity->hasScheduledEndTime()\n ? $activity->getScheduledEndTime()->addMinutes(15)\n : $activity->getActualEndTime();\n }\n\n return [$from, $to];\n }\n\n /**\n * Determines the appropriate activity field name for querying Salesforce events.\n *\n * This method follows a hierarchy to determine the field name:\n * 1. Uses the playbook's activity field if it exists and is in the profile's accessible fields\n * 2. Falls back to the default activity field if the profile has no event fields configured\n * 3. Returns null if no suitable field is found\n *\n * @param Activity $activity The activity to determine the field for\n *\n * @return string|null The field name to use in queries, or null if none is available\n */\n private function getActivityFieldName(Activity $activity): ?string\n {\n if ($this->profile === null) {\n $this->logger->warning('[Salesforce] Cannot determine activity field - profile not found', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n $profileEventFields = $this->profile->getFieldsAsArray('event');\n\n if (empty($profileEventFields)) {\n $defaultActivityField = $this->getDefaultActivityField(Field::OBJECT_EVENT);\n $defaultFieldName = $defaultActivityField?->getAttribute('crm_provider_id');\n // Profile not yet synced — fall back to the default activity field.\n // There is a small chance that the profile won't have Default Activity Type field access\n // in which case the query will fail.\n // This is however an edge case and should be reviewed for profile sync issues.\n Sentry::withScope(function (\\Sentry\\State\\Scope $scope) use ($defaultFieldName): void {\n $scope->setContext('details', [\n 'profileId' => $this->profile->id,\n 'defaultField' => $defaultFieldName,\n ]);\n Sentry::captureMessage(\n '[Salesforce] Profile event fields empty, falling back to default activity field.',\n \\Sentry\\Severity::warning()\n );\n });\n\n return $defaultFieldName;\n }\n\n $playbook = $this->getPlaybook($activity->getUser());\n\n if (! is_null($playbook) && ! is_null($playbook->getActivityField())) {\n $playbookFieldName = $playbook->getActivityField()->getAttribute('crm_provider_id');\n\n if (in_array($playbookFieldName, $profileEventFields, true)) {\n return $playbookFieldName;\n }\n\n $this->logger->warning('[Salesforce] Playbook activity field not found in profile fields', [\n 'activityId' => $activity->getUuid(),\n 'playbookField' => $playbookFieldName,\n 'profileId' => $this->profile->id,\n ]);\n }\n\n return null;\n }\n\n private function buildFetchRelatedEventQuery(Activity $activity): string\n {\n $hasWho = $activity->lead_id || $activity->contact_id;\n\n $activityFieldName = $this->getActivityFieldName($activity);\n $fields = array_filter(['Id', 'Description', 'OwnerId', $activityFieldName]);\n\n $ownerCondition = '(OwnerId = :ownerId OR CreatedById = :ownerId)';\n\n $query = '\n SELECT ' . implode(',', $fields) . '\n FROM Event\n WHERE ' . $ownerCondition . '\n AND IsArchived = false\n AND IsAllDayEvent = false\n AND StartDateTime >= :from\n AND EndDateTime <= :to\n AND (';\n\n $operator = '';\n if ($activity->account_id) {\n // This covers events tied to a related contact or opportunity too.\n $query .= 'AccountId = :accountId';\n\n $operator = ' OR ';\n }\n\n if ($hasWho) {\n $query .= $operator . 'WhoId = :whoId';\n\n // If we are also going to check on a specific opportunity, set that up.\n if ($activity->opportunity_id) {\n $query .= ' OR WhatId = :whatId';\n }\n }\n\n $query .= ') ORDER BY LastModifiedDate DESC';\n\n return $query;\n }\n\n public function fetchProspect(array $task): array\n {\n $lead = $account = $opportunity = $contact = $stage = $countryCode = null;\n $externalId = $task['WhoId'] ?? null;\n\n // Lead or Contact\n if ($externalId) {\n try {\n [$lead, $account, $opportunity, $contact, $stage, $countryCode] = $this->parseRecords($externalId);\n } catch (\\InvalidArgumentException $exception) {\n // Invalid object type.\n }\n }\n\n // If we happen to know the opportunity or account from the Task, figure that out.\n if (empty($task['WhatId']) === false) {\n // WhatId could be either Account ID or Opportunity ID.\n // If WhatId is Opportunity ID, get the opportunity and stage from the CRM.\n try {\n [, $account, $opportunity, , $stage, ] = $this->parseRecords($task['WhatId']);\n } catch (\\InvalidArgumentException $exception) {\n // Invalid object type.\n }\n }\n\n return [$lead, $account, $opportunity, $contact, $stage, $countryCode];\n }\n\n /**\n * Save activity transcription summary as note\n */\n public function saveTranscriptionSummaryAsNote(\n ActivityContract $activity,\n string $title,\n string $body,\n ?string $objectId,\n ?NoteObject $noteObject = null,\n ): ?string {\n return $this->saveNote($title, $body, (string) $objectId);\n }\n\n public function getObjectByFilterConditions(string $objectType, array $fields, array $filters): ?array\n {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $query = $queryBuilder->buildObjectSearchQuery($objectType, $fields, $filters);\n\n try {\n $objects = $this->queryHandler->query($query, $filters);\n if ($objects->count() === 1) {\n return $objects->current();\n }\n } catch (\\Exception $e) {\n $this->logger->info('[Salesforce] Failed to execute query', [\n 'query' => $query,\n 'error' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n private function getCustomProfileRules(TeamRepository $teamRepository): array\n {\n $teamSettings = $teamRepository->getTeamSetting($this->team, 'custom_profile_validation');\n\n if ($teamSettings instanceof TeamSettings && $teamSettings->getValueType() === 'array') {\n $customRules = json_decode($teamSettings->getValue(), true);\n if (is_array($customRules)) {\n return $customRules;\n }\n }\n\n return [];\n }\n\n private function customProfileValidation(array $crmUser, array $customRules): bool\n {\n foreach ($customRules as $customRule) {\n if ($crmUser[$customRule['field']] !== $customRule['value']) {\n return false;\n }\n }\n\n return true;\n }\n\n /**\n * When syncing Contact / Lead / Account / Opportunity / Stage crm entities,\n * validate and restore locally trashed objects,\n * before updating them. Objects are identified by CrmProviderId\n */\n private function restoreAnyTrashedEntity(HasMany $targetEntity, string $crmProviderId): void\n {\n $recordExists = $targetEntity->withTrashed()->where(['crm_provider_id' => $crmProviderId])->first();\n if ($recordExists && $recordExists->trashed()) {\n $recordExists->restore();\n }\n }\n\n #[\\Override] public function supportsNotes(): bool\n {\n return true;\n }\n\n private function getOwnerProfile(?string $ownerId): ?Profile\n {\n if ($ownerId === null) {\n return null;\n }\n\n return $this->config->profiles()\n ->where('crm_provider_id', $ownerId)\n ->first();\n }\n}","depth":2,"bounds":{"left":0.44448137,"top":0.42697525,"width":0.28490692,"height":0.57302475},"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Salesforce;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse Illuminate\\Database\\Eloquent\\Relations\\HasMany;\nuse Illuminate\\Events\\Dispatcher;\nuse Illuminate\\Support\\Facades\\Cache;\nuse Illuminate\\Support\\Facades\\Log;\nuse Illuminate\\Support\\Str;\nuse Jiminny\\Component\\Country\\CountriesMap;\nuse Jiminny\\Contracts\\Acl\\PermissionEnum;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\FetchRelatedActivityInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\ImportsBusinessProcessesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\SalesforceBatchSyncInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\SalesforceInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteNoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SearchTaskInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SendSummaryToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SupportsObjectTypeParseInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmProfileRecordTypesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Enums\\CrmObject;\nuse Jiminny\\Events\\Activities\\Crm\\LeadConverted;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpBadRequestException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Exceptions\\NoResultsException;\nuse Jiminny\\Exceptions\\ServiceUnavailableException;\nuse Jiminny\\Jobs\\Crm\\NoteObject;\nuse Jiminny\\Jobs\\Crm\\MatchActivitiesToNewOpportunity;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Calendar\\CalendarEvent;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Contracts\\ActivityContract;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Configuration;\nuse Jiminny\\Models\\Crm\\ContactRole;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Crm\\RecordType;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\TeamSettings;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\ContactRoleRepository;\nuse Jiminny\\Repositories\\Crm\\FieldRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Repositories\\Crm\\RecordTypeFieldValuesRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\Helpers\\ArrayIterator;\nuse Jiminny\\Services\\Crm\\MatchDomainByEmailInterface;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Services\\Crm\\ResolveCompanyNameByEmailTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\Fields\\FieldHelper;\nuse Jiminny\\Services\\Crm\\Salesforce\\Fields\\FieldTypeConverter;\nuse Jiminny\\Services\\Crm\\Salesforce\\Fields\\ValueNormalizer;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\FollowupActivityTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\LogActivityTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\RecordManipulationsTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\SyncFieldsTrait;\nuse Jiminny\\Utils\\CurrencyFormatter;\nuse Jiminny\\Utils\\StringUtil;\nuse Ramsey\\Uuid\\Uuid;\nuse Sentry\\Laravel\\Facade as Sentry;\n\nclass Service extends BaseService implements\n SalesforceInterface,\n SalesforceBatchSyncInterface,\n SyncCrmEntitiesInterface,\n SyncCrmProfileRecordTypesInterface,\n ImportsBusinessProcessesInterface,\n RemoteEntityManipulationInterface,\n FetchRelatedActivityInterface,\n SendSummaryToCrmInterface,\n MatchDomainByEmailInterface,\n SearchTaskInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n SupportsObjectTypeParseInterface,\n RemoteNoteEntityManipulationInterface,\n VerifyTaskExistsInterface\n{\n use ResolveCompanyNameByEmailTrait;\n use SyncFieldsTrait;\n use DeleteObjectsTrait;\n use RecordManipulationsTrait;\n use ServiceTraits\\BatchSyncTrait;\n use FollowupActivityTrait;\n use LogActivityTrait;\n\n /**\n * Note Body Limit for the Old Note-Taking Tool\n *\n * @var int\n */\n private const int CLASSIC_NOTE_MAX_LENGTH = 32000;\n\n /**\n * Note Content Limit for the New Notes\n *\n * @var int\n */\n private const int ENHANCED_NOTE_MAX_LENGTH = 50000000;\n\n private const string INSTALLED_PACKAGE_ID = '033Tw0000007bKbIAI';\n\n private const int CACHE_TTL = 600;\n\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day - 86400\n\n /**\n * @var Client\n */\n protected $client;\n\n protected PayloadBuilder $payloadBuilder;\n protected QueryHandler $queryHandler;\n\n private OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n\n public function __construct(\n Client $client,\n PayloadBuilder $payloadBuilder,\n protected Dispatcher $eventDispatcher,\n private readonly CountriesMap $countriesMap,\n private readonly ProspectPhotoPathService $prospectPhotoPathService,\n ) {\n parent::__construct();\n\n $this->client = $client;\n $this->payloadBuilder = $payloadBuilder;\n $this->queryHandler = app(QueryHandler::class, [\n 'client' => $this->client,\n 'logger' => $this->logger,\n ]);\n $this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n }\n\n public function getDisplayName(): string\n {\n return 'Salesforce';\n }\n\n public function getJobDelay(): int\n {\n return 1;\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n return $user->getSocialAccount(SocialAccount::PROVIDER_SALESFORCE);\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($activity, $crmProviderId) {\n $playbook = $this->getPlaybookFromActivity($activity);\n\n if ($playbook === null) {\n $this->logger->warning('[Salesforce] Cannot verify task - no playbook found', [\n 'activity' => $activity->getId(),\n 'crm_provider_id' => $crmProviderId,\n ]);\n\n return false;\n }\n\n $objectType = $playbook->getActivityType() === Playbook::ACTIVITY_TYPE_EVENT ? 'Event' : 'Task';\n\n try {\n $record = $this->getRecord($objectType, $crmProviderId, ['Id', 'IsDeleted']);\n\n return ! empty($record) && ($record['IsDeleted'] ?? false) === false;\n } catch (HttpNotFoundException|HttpBadRequestException) {\n $this->logger->info('[Salesforce] Activity record not found during verification', [\n 'activity' => $activity->getId(),\n 'object_type' => $objectType,\n 'crm_provider_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n });\n }\n\n public function query(string $queryToRun, array $parameters = []): QueryIterator\n {\n // Due to poorly designed external calls, this method cannot be entirely removed\n return $this->queryHandler->query($queryToRun, $parameters);\n }\n\n /*=========== Organization Information ===============*/\n\n /**\n * Get a list of all the API Versions for the instance.\n *\n * @throws CrmException\n *\n * @return mixed\n *\n */\n public function getApiVersions()\n {\n $url = $this->config->crm_base_url . '/services/data';\n\n $response = $this->client->get($url);\n\n return json_decode($response->getBody(), true);\n }\n\n /**\n * Gets the valid recordTypes for a given Salesforce Object via the describe API.\n */\n private function getRecordTypes(string $crmObject): array\n {\n $url = $this->client->getObjectsUrl() . $crmObject . '/describe';\n\n $response = $this->client->get($url);\n $jsonResponse = json_decode($response->getBody(), true);\n\n $fields = [];\n foreach ($jsonResponse['recordTypeInfos'] as $row) {\n $fields[] = ['recordTypeId' => $row['recordTypeId'], 'default' => $row['defaultRecordTypeMapping']];\n }\n\n return $fields;\n }\n\n /**\n * Convert raw field data into a format compatible with CRM APIs.\n */\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return ValueNormalizer::normalize($fieldType, $fieldValue, $internal);\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n $defaultFields = ($activityType === Playbook::ACTIVITY_TYPE_TASK)\n ? FieldDefinitions::defaultTaskFields()\n : FieldDefinitions::defaultEventFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n // Setup the activity field as the default Type.\n /** @var Field $activityField */\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'Type',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK, Playbook::ACTIVITY_TYPE_EVENT];\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n\n $fieldFilter = ($activityType === Playbook::ACTIVITY_TYPE_TASK)\n ? FieldDefinitions::taskFollowupFieldsFilter()\n : FieldDefinitions::eventFollowupFieldsFilter();\n\n foreach ($fieldFilter as $eachFilter) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $eachFilter);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n /**\n * This one is now called only when ImportActivityTypes is triggered or SyncFieldMetadata executed manually\n * Regular sync now uses SharedSyncFieldsTrait -> syncSingleObjectType\n * Needs to be replaced later on\n */\n public function syncField(Field $field): void\n {\n try {\n if (FieldHelper::isCustomField($field)) {\n $query = '\n SELECT\n Id, Metadata, TableEnumOrId\n FROM\n CustomField\n WHERE\n DeveloperName = :fieldName\n AND\n TableEnumOrId = :fieldType\n AND\n NamespacePrefix = :namespacePrefix';\n\n // We need to constrain the field lookup to the object, in case it's used in multiple places.\n $objectType = \\in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true)\n ? 'activity'\n : $field->object_type;\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'fieldName' => substr($field->crm_provider_id, 0, -\\strlen('__c')),\n 'fieldType' => ucfirst($objectType),\n\n // This is used to ensure we only consider the field within the org, not installed packages.\n 'namespacePrefix' => 'null',\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n // Sync field metadata.\n $metadata = $sfField['Metadata'];\n\n $field->description = mb_strimwidth($metadata['description'] ?? '', 0, 191);\n $field->label = mb_strimwidth($metadata['label'] ?? '', 0, Field::LABEL_MAX_LENGTH);\n $field->type = $this->convertFieldType($metadata['type'], $field->getEntityName());\n $field->is_mandatory = ($metadata['required'] === true);\n $field->length = $metadata['length'];\n $field->default_value = mb_strimwidth(trim($metadata['defaultValue'] ?? '', '\"'), 0, 191);\n $field->save();\n } else {\n $query = '\n SELECT\n Id, DataType, DeveloperName, Label, Length, Description\n FROM\n FieldDefinition\n WHERE\n DurableId = :entityName';\n\n $entityName = $field->getEntityName();\n $sfFields = $this->queryHandler->metadata($query, [\n 'entityName' => $entityName,\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n $convertedType = $this->convertFieldType($sfField['DataType'], $entityName);\n $label = mb_strimwidth($sfField['Label'], 0, Field::LABEL_MAX_LENGTH);\n\n if ($field->isBusinessType()) {\n $label = 'Opportunity Type';\n }\n\n $field->description = mb_strimwidth($sfField['Description'], 0, Field::DESCRIPTION_MAX_LENGTH);\n $field->label = $label;\n $field->type = $convertedType;\n $field->length = $sfField['Length'];\n $field->save();\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n private function convertFieldType(string $from, ?string $entityName = null): string\n {\n $converter = new FieldTypeConverter();\n\n return $converter->convert($from, $entityName);\n }\n\n /**\n * @inheritdoc\n */\n public function importPicklistValues(Field $field): array\n {\n $values = [];\n $fieldValues = [];\n\n try {\n if (FieldHelper::isCustomField($field)) {\n $query = '\n SELECT\n Id, Metadata, TableEnumOrId\n FROM\n CustomField\n WHERE\n DeveloperName = :fieldName\n AND\n TableEnumOrId = :fieldType\n AND\n NamespacePrefix = :namespacePrefix';\n\n // We need to constrain the field lookup to the object, in case it's used in multiple places.\n $objectType = \\in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true) ?\n 'activity' : $field->object_type;\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'fieldName' => substr($field->crm_provider_id, 0, -\\strlen('__c')),\n 'fieldType' => ucfirst($objectType),\n // This is used to ensure we only consider the field within the org, not installed packages.\n 'namespacePrefix' => 'null',\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n $valueSet = $sfField['Metadata']['valueSet'];\n\n if ($valueSet['valueSetName'] === null) {\n // Local picklist values can be obtained easily.\n $picklistValues = $valueSet['valueSetDefinition']['value'];\n } else {\n // But for some fields, we just get the Global Value Picklist pointer so need to do more work.\n $picklistValues = $this->importGlobalValuePicklistValues($valueSet['valueSetName']);\n }\n\n // Import all active values.\n foreach ($picklistValues as $i => $sfFieldValue) {\n // Setup default value.\n if ($sfFieldValue['default']) {\n $field->update(['default_value' => $sfFieldValue['valueName']]);\n }\n\n // This comes through as null if active (lol).\n if ($sfFieldValue['isActive'] !== false) {\n $values[] = [\n 'value' => $sfFieldValue['valueName'],\n 'label' => $sfFieldValue['valueName'],\n 'sequence' => $i,\n 'is_default' => $sfFieldValue['default'],\n ];\n }\n }\n } else {\n $objectFields = $this->getObjectFields($field->object_type);\n $fieldId = $field->crm_provider_id;\n\n // Only work with our field of interest.\n $objectField = array_filter($objectFields, function ($item) use ($fieldId) {\n return $item['name'] === $fieldId;\n });\n\n $objectField = array_shift($objectField);\n if (empty($objectField['picklistValues']) === false) {\n foreach ($objectField['picklistValues'] as $i => $sfFieldValue) {\n // Skip inactive values.\n if ($sfFieldValue['active'] === false) {\n continue;\n }\n\n // Setup default value.\n if ($sfFieldValue['defaultValue']) {\n $field->update(['default_value' => $sfFieldValue['value']]);\n }\n\n $values[] = [\n 'value' => $sfFieldValue['value'],\n 'label' => $sfFieldValue['label'],\n 'sequence' => $i,\n 'is_default' => $sfFieldValue['defaultValue'],\n ];\n }\n }\n }\n\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n $value['value'] = substr($value['value'] ?? '', 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n if (($key = array_search($value['value'], $fieldsToPurge, true)) !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n // Get IDs of the values to be deleted\n $valuesToDelete = $field->values()->whereIn('value', $fieldsToPurge);\n $valuesToDeleteIds = $valuesToDelete->pluck('id');\n if (! $valuesToDeleteIds->isEmpty()) {\n $recordTypeFieldValuesRepository = app(RecordTypeFieldValuesRepository::class);\n $recordTypeFieldValuesRepository->deleteForCrmFieldValueIds($valuesToDeleteIds->toArray());\n\n // Now safely delete from crm_field_values\n $valuesToDelete->delete();\n }\n\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n return $fieldValues;\n }\n\n /**\n * Gets values from Global Value Picklists.\n */\n private function importGlobalValuePicklistValues(string $picklistName): array\n {\n $query = '\n SELECT\n Metadata\n FROM\n GlobalValueSet\n WHERE\n DeveloperName = :picklistName\n LIMIT 1';\n\n try {\n $sfValues = $this->queryHandler->metadata($query, [\n 'picklistName' => $picklistName,\n ]);\n\n // There is always 1 result at this point.\n $sfValue = $sfValues->current();\n\n return $sfValue['Metadata']['customValue'];\n } catch (NoResultsException $noResultsException) {\n // Nothing returned.\n\n return [];\n }\n }\n\n /**\n * @inheritdoc\n */\n public function syncProfileRecordTypes(): void\n {\n $objectTypes = [\n 'lead',\n 'account',\n 'contact',\n 'opportunity',\n 'task',\n 'event',\n ];\n\n foreach ($objectTypes as $objectType) {\n try {\n $crmRecordTypes = $this->getRecordTypes(ucfirst($objectType));\n\n foreach ($crmRecordTypes as $crmRecordType) {\n // If the record type is default and not the Master type, set this.\n if ($crmRecordType['default'] && $crmRecordType['recordTypeId'] !== '012000000000000AAA') {\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $crmRecordType['recordTypeId'])\n ->first();\n\n if ($recordType) {\n $this->profile->{$objectType . '_record_type_id'} = $recordType->id;\n }\n }\n }\n } catch (HttpNotFoundException $exception) {\n Log::error('No access to ' . $objectType . ' object, skipping...');\n\n // XXX: should we log this fact somewhere?\n continue;\n }\n }\n\n if ($this->profile->isDirty()) {\n $this->profile->save();\n }\n }\n\n /**\n * Gets business processes.\n */\n public function importBusinessProcesses(): void\n {\n $query = '\n SELECT\n Id, IsActive, Name, TableEnumOrId\n FROM\n BusinessProcess\n WHERE\n TableEnumOrId IN (\\'Lead\\',\\'Opportunity\\')';\n\n try {\n $sfProcesses = $this->queryHandler->query($query);\n\n // Upsert all processes for the team.\n foreach ($sfProcesses as $sfProcess) {\n /** @var BusinessProcess $businessProcess */\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $sfProcess['Id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => $sfProcess['Name'],\n 'type' => $sfProcess['TableEnumOrId'] === 'Lead' ? 'lead' : 'opportunity',\n 'is_selectable' => $sfProcess['IsActive'],\n ]);\n\n $this->importBusinessProcessStages($businessProcess);\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n /**\n * Gets business process stages.\n */\n private function importBusinessProcessStages(BusinessProcess $businessProcess): void\n {\n $query = '\n SELECT\n Metadata\n FROM\n BusinessProcess\n WHERE\n Id = :processId';\n\n try {\n $stages = [];\n $sfProcessStages = $this->queryHandler->metadata($query, [\n 'processId' => $businessProcess->crm_provider_id,\n ]);\n\n // There is always 1 result at this point.\n $sfProcessStage = $sfProcessStages->current();\n\n // Upsert all processes for the team.\n foreach ($sfProcessStage['Metadata']['values'] as $sfProcessStage) {\n $sanitizedName = urldecode($sfProcessStage['valueName']); // Must decode: \"%2C\" becomes \",\" etc.\n\n $stage = $businessProcess->crm->stages()\n // This MUST match on label because this API doesn't use API Name.\n ->where('label', $sanitizedName)\n ->where('type', $businessProcess->type)\n ->where('is_selectable', 1)\n ->first();\n\n if ($stage) {\n $stages[] = $stage->id;\n }\n }\n\n $businessProcess->stages()->sync($stages);\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n /**\n * Gets record types.\n */\n public function importRecordTypes(): void\n {\n $query = '\n SELECT\n Id, IsActive, Name, BusinessProcessId, SobjectType\n FROM\n RecordType';\n\n try {\n $sfRecordTypes = $this->queryHandler->query($query);\n\n // Upsert all record types for the process.\n foreach ($sfRecordTypes as $sfRecordType) {\n $businessProcess = null;\n if ($sfRecordType['BusinessProcessId']) {\n $businessProcess = $this->config->businessProcesses()\n ->where('crm_provider_id', $sfRecordType['BusinessProcessId'])\n ->first();\n }\n\n /** @var RecordType $recordType */\n $recordType = $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $sfRecordType['Id'],\n ], [\n 'team_id' => $this->team->id,\n 'type' => mb_strtolower($sfRecordType['SobjectType']),\n 'name' => $sfRecordType['Name'],\n 'is_selectable' => $sfRecordType['IsActive'],\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n $this->importRecordTypeFieldValues($recordType);\n }\n } catch (NoResultsException $noResultsException) {\n // Do nothing.\n }\n }\n\n /**\n * Import record type - field value mappings. This only works for standard fields.\n */\n private function importRecordTypeFieldValues(RecordType $recordType): void\n {\n try {\n $query = '\n SELECT\n Metadata\n FROM\n RecordType\n WHERE\n Id = :recordTypeId';\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'recordTypeId' => $recordType->crm_provider_id,\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n // Sync field metadata.\n $picklists = $sfField['Metadata']['picklistValues'];\n\n foreach ($picklists as $picklist) {\n $field = $this->config->fields()->where([\n 'type' => Field::TYPE_PICKLIST,\n 'object_type' => $recordType->type,\n 'crm_provider_id' => $picklist['picklist'],\n ])->first();\n\n if ($field) {\n $fieldValues = [];\n\n foreach ($picklist['values'] as $value) {\n // Must decode: \"%2C\" becomes \",\" etc.\n $fieldValue = $field->values()\n ->where('value', urldecode($value['valueName']))\n ->first();\n\n if ($fieldValue) {\n $fieldValues[] = $fieldValue->id;\n }\n }\n\n $recordType->fieldValues()->sync($fieldValues);\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n /**\n * @inheritdoc\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $params = [];\n $missingStage = null;\n if ($types === null) {\n $types = [Stage::TYPE_LEAD, Stage::TYPE_OPPORTUNITY];\n }\n\n foreach ($types as $type) {\n if ($type === Stage::TYPE_LEAD) {\n $query = '\n SELECT\n Id, ApiName, MasterLabel, SortOrder\n FROM\n LeadStatus';\n } else {\n $query = '\n SELECT\n Id, ApiName, MasterLabel, IsActive, SortOrder, DefaultProbability\n FROM\n OpportunityStage';\n }\n\n if ($missingStageName) {\n $escapedStageName = ValueNormalizer::replaceQueryWithStringLiterals($missingStageName);\n\n $query .= ' WHERE ApiName = :stageName';\n\n $params = [\n 'stageName' => $escapedStageName,\n ];\n }\n\n try {\n $sfStages = $this->queryHandler->query($query, $params);\n } catch (NoResultsException $exception) {\n $sfStages = [];\n }\n\n $missingStage = null;\n\n // Upsert all stages for the team.\n foreach ($sfStages as $sfStage) {\n $selectable = true;\n if (array_key_exists('IsActive', $sfStage)) {\n $selectable = $sfStage['IsActive'];\n }\n\n $this->restoreAnyTrashedEntity($this->config->stages(), $sfStage['Id']);\n\n $stage = $this->config->stages()->updateOrCreate([\n 'crm_provider_id' => $sfStage['Id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($sfStage['ApiName'], 0, 50),\n 'label' => mb_strimwidth($sfStage['MasterLabel'], 0, 191),\n 'type' => $type,\n 'sequence' => $sfStage['SortOrder'] ?? 0,\n 'is_selectable' => $selectable,\n 'probability' => $sfStage['DefaultProbability'] ?? null,\n ]);\n\n if ($missingStageName && $missingStageName === $sfStage['ApiName']) {\n $missingStage = $stage;\n }\n }\n\n if ($missingStageName && $missingStage === null) {\n // If they requested a stage that still doesn't exist, it must be inactive so lazy create it.\n $missingStage = $this->config->stages()->create([\n 'crm_provider_id' => Uuid::uuid4(),\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($missingStageName, 0, 50),\n 'label' => mb_strimwidth($missingStageName, 0, 191),\n 'type' => $type,\n 'sequence' => 0,\n 'is_selectable' => 0,\n ]);\n }\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int\n {\n $syncCount = 0;\n $fields = $this->getAllFieldsAsArray('lead');\n if (\\in_array('Id', $fields, true) === false) {\n return $syncCount;\n }\n\n $query = '\n SELECT ' . rtrim(implode(',', $fields), ',') . '\n FROM Lead\n WHERE LastModifiedDate > :since\n ORDER BY LastModifiedDate ASC';\n\n try {\n $sfLeads = $this->queryHandler->query($query, [\n 'since' => $since->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($sfLeads as $sfLead) {\n // Only sync if previously imported.\n if ($this->hasLead($sfLead['Id'])) {\n $this->importLead($sfLead);\n $syncCount++;\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::LEAD);\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLead(string $crmId): ?Lead\n {\n $fields = $this->getAllFieldsAsArray('lead');\n\n $sfLead = $this->getRecord('Lead', $crmId, $fields);\n\n return $this->importLead($sfLead);\n }\n\n private function importLead($crmData): ?Lead\n {\n /** @var ?Stage $stage */\n $stage = null;\n if (isset($crmData['Status'])) {\n // Get the current stage.\n $stage = $this->config\n ->stages()\n ->where('name', $crmData['Status'])\n ->where('type', Stage::TYPE_LEAD)\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_LEAD], $crmData['Status']);\n }\n }\n\n // If we have no way of importing this, just return null :(\n if ($stage === null) {\n return null;\n }\n\n $countryCode = $crmData['CountryCode'] ?? null;\n\n // Salesforce allows custom \"countries\" to be created. Disregard these.\n if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {\n $countryCode = null;\n }\n\n // If we have no country code, try to parse it from the country name.\n if ($countryCode === null && empty($crmData['Country']) !== false) {\n $countryCode = $this->convertCountryNameToCode($crmData['Country']);\n }\n\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($crmData['Phone'] ?? '', 0, 25);\n $parsedNumber = parsePhoneNumber($countryCode, $number);\n\n $mobilePhone = null;\n if (empty($crmData['MobilePhone']) === false) {\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($crmData['MobilePhone'], 0, 25);\n $mobilePhone = phone_e164($countryCode, $number);\n }\n\n $convertedDate = null;\n $convertedAccount = null;\n $convertedOpportunity = null;\n $convertedContact = null;\n\n if ($crmData['IsConverted'] == 'true') {\n $convertedDate = $crmData['ConvertedDate'];\n\n if (empty($crmData['ConvertedAccountId']) === false) {\n $convertedAccount = $this->config\n ->accounts()\n ->where('crm_provider_id', $crmData['ConvertedAccountId'])\n ->first();\n\n if ($convertedAccount === null) {\n try {\n $convertedAccount = $this->syncAccount($crmData['ConvertedAccountId']);\n } catch (HttpNotFoundException $exception) {\n // Probably the user has no permissions to access the converted data.\n }\n }\n }\n\n if (empty($crmData['ConvertedOpportunityId']) === false) {\n $convertedOpportunity = $this->config\n ->opportunities()\n ->where('crm_provider_id', $crmData['ConvertedOpportunityId'])\n ->first();\n\n if ($convertedOpportunity === null) {\n try {\n $convertedOpportunity = $this->syncOpportunity($crmData['ConvertedOpportunityId']);\n } catch (HttpNotFoundException $exception) {\n // Probably the user has no permissions to access the converted data.\n }\n }\n }\n\n if (empty($crmData['ConvertedContactId']) === false) {\n $convertedContact = $this->team\n ->crm\n ->contacts()\n ->where('crm_provider_id', $crmData['ConvertedContactId'])\n ->first();\n\n if ($convertedContact === null) {\n try {\n $convertedContact = $this->syncContact($crmData['ConvertedContactId']);\n } catch (HttpNotFoundException $exception) {\n // Probably the user has no permissions to access the converted data.\n }\n }\n }\n }\n\n if (empty($crmData['Company'])) {\n $company = 'Unknown';\n } else {\n $company = mb_strimwidth($crmData['Company'], 0, 191);\n }\n\n $domain = null;\n if (empty($crmData['Website']) === false) {\n $domain = mb_strimwidth($crmData['Website'], 0, 191);\n $domain = StringUtil::resolveDomain($domain);\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $data = [\n 'team_id' => $this->team->id,\n 'user_id' => $profile?->user_id,\n 'owner_id' => $crmData['OwnerId'] ?? '',\n 'company' => $company,\n 'domain' => $domain,\n 'name' => $crmData['Name'] ? mb_strimwidth($crmData['Name'], 0, 191) : '',\n 'title' => $crmData['Title'] ? mb_strimwidth($crmData['Title'], 0, 128) : null,\n 'email' => $crmData['Email'] ? mb_strimwidth($crmData['Email'], 0, 80) : null,\n 'phone' => $parsedNumber['phone'],\n 'ext' => $parsedNumber['ext'] ?? null,\n 'mobile_phone' => $mobilePhone,\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['Id'],\n modelType: Lead::class,\n fileName: $crmData['Id'],\n avatarText: $crmData['Name']\n ),\n 'stage_id' => $stage->id,\n 'record_type_id' => null,\n 'converted_at' => $convertedDate,\n 'converted_account_id' => $convertedAccount->id ?? null,\n 'converted_opportunity_id' => $convertedOpportunity->id ?? null,\n 'converted_contact_id' => $convertedContact->id ?? null,\n 'country_code' => $countryCode,\n 'remotely_created_at' => $createdDate,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->leads(), $crmData['Id']);\n\n /** @var Lead $lead */\n $lead = $this->config->leads()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n if ($lead->wasChanged('converted_at') && $lead->getConvertedAt() !== null) {\n $this->eventDispatcher->dispatch(new LeadConverted($lead));\n }\n\n $this->handleObjectDeletion($lead, $crmData);\n\n if ($lead->trashed()) {\n return null;\n }\n\n return $lead;\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccounts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n $fields = $this->getAllFieldsAsArray('account');\n\n if (\\in_array('Id', $fields, true) === false) {\n return $syncCount;\n }\n\n $query = '\n SELECT ' . rtrim(implode(',', $fields), ',') . '\n FROM Account\n WHERE LastModifiedDate > :since\n ORDER BY LastModifiedDate ASC';\n\n try {\n $sfAccounts = $this->queryHandler->query($query, [\n 'since' => $since->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($sfAccounts as $sfAccount) {\n // Only sync if previously imported.\n if ($this->hasAccount($sfAccount['Id'])) {\n $this->importAccount($sfAccount);\n $syncCount++;\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::ACCOUNT);\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccount(string $crmId): ?Account\n {\n $fields = $this->getAllFieldsAsArray('account');\n if (! in_array('Id', $fields, true)) {\n $this->logger->info('[Salesforce] Sync account cancelled. Fields are not available.', [\n 'crmId' => $crmId,\n 'userId' => $this->profile->getUserId(),\n ]);\n\n return null;\n }\n\n $sfAccount = $this->getRecord('Account', $crmId, $fields);\n\n return $this->importAccount($sfAccount);\n }\n\n private function importAccount($crmData): Account\n {\n $countryCode = $crmData['BillingCountryCode'] ?? $crmData['ShippingCountryCode'] ?? null;\n\n // Salesforce allows custom \"countries\" to be created. Disregard these.\n if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {\n $countryCode = null;\n }\n\n // If we have no country code, try to parse it from the country names.\n if ($countryCode === null && empty($crmData['BillingCountry']) === false) {\n $countryCode = $this->convertCountryNameToCode($crmData['BillingCountry']);\n }\n\n if ($countryCode === null && empty($crmData['ShippingCountry']) === false) {\n $countryCode = $this->convertCountryNameToCode($crmData['ShippingCountry']);\n }\n\n if (empty($crmData['Phone']) === false) {\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($crmData['Phone'], 0, 25);\n $parsedNumber = parsePhoneNumber($countryCode, $number);\n } else {\n $parsedNumber = [];\n }\n\n $industry = null;\n if (empty($crmData['Industry']) === false) {\n $industry = mb_strimwidth($crmData['Industry'], 0, 40);\n }\n\n $domain = null;\n if (empty($crmData['Website']) === false) {\n $domain = mb_strimwidth($crmData['Website'], 0, 191);\n $domain = StringUtil::resolveDomain($domain);\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $data = [\n 'team_id' => $this->team->id,\n 'user_id' => $profile?->user_id,\n 'owner_id' => $crmData['OwnerId'],\n 'name' => mb_strimwidth($crmData['Name'], 0, 191),\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['Id'],\n modelType: Account::class,\n fileName: $crmData['Id'],\n avatarText: $crmData['Name']\n ),\n 'industry' => $industry,\n 'domain' => $domain,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'ext' => $parsedNumber['ext'] ?? null,\n 'country_code' => $countryCode,\n 'remotely_created_at' => $createdDate,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->accounts(), $crmData['Id']);\n\n /** @var Account $account */\n $account = $this->config->accounts()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n $this->handleObjectDeletion($account, $crmData);\n\n return $account;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOpportunities(array $parameters, ?string $strategy = null): int\n {\n $strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);\n\n $syncCount = 0;\n $logParams = $parameters;\n $parameters['profile'] = $this->profile;\n $logParams['user'] = $this->profile->getUserId();\n\n if (count($strategies) > 1) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Multiple sync strategies used', [\n 'teamId' => $this->team->getUuid(),\n 'params' => $logParams,\n 'strategies_count' => count($strategies),\n ]);\n }\n\n foreach ($strategies as $syncStrategy) {\n $name = $syncStrategy->getStrategyName();\n\n try {\n $sfOpportunities = $syncStrategy->fetchOpportunities($parameters);\n $totalRecords = $sfOpportunities->count();\n\n foreach ($sfOpportunities as $sfOpportunity) {\n $this->importOpportunity($sfOpportunity);\n $syncCount++;\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n $this->logger->warning('[' . $this->getDisplayName() . '] No opportunities found', [\n 'teamId' => $this->team->getUuid(),\n 'name' => $name,\n 'params' => $logParams,\n 'reason' => $noResultsException->getMessage(),\n ]);\n } catch (CrmException $crmException) {\n // Nothing to sync.\n $this->logger->warning('[' . $this->getDisplayName() . '] Opportunity sync failed', [\n 'teamId' => $this->team->getUuid(),\n 'name' => $name,\n 'params' => $logParams,\n 'reason' => $crmException->getMessage(),\n ]);\n }\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::OPPORTUNITY, ['params' => $logParams]);\n\n // debug to see how if count of opportunities reaches 1000\n if ($syncCount >= 1000) {\n $this->logger->info(\n '[' . $this->getDisplayName() . '] Sync Opportunities - count warning',\n [\n 'team_id' => $this->team->getId(),\n 'params' => $logParams,\n 'count' => $syncCount,\n 'strategies_count' => count($strategies),\n 'total_records' => $totalRecords ?? null,\n ]\n );\n }\n\n return $syncCount;\n }\n\n\n /**\n * @inheritdoc\n */\n public function syncOpportunity(string $crmId): ?Opportunity\n {\n $strategy = $this->opportunitySyncStrategyResolver->resolve(\n $this->config,\n OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY\n );\n\n $parameters = [\n 'profile' => $this->profile,\n 'crm_id' => $crmId,\n ];\n\n try {\n $sfOpportunity = $strategy->fetchOpportunities($parameters);\n } catch (HttpNotFoundException $e) {\n $this->logger->info('[' . $this->getDisplayName() . '] Opportunity not found', [\n 'teamId' => $this->team->id_string,\n 'crmId' => $crmId,\n ]);\n\n return null;\n } catch (CrmException $crmException) {\n $this->logger->info('[' . $this->getDisplayName() . '] Opportunity sync failed', [\n 'teamId' => $this->team->id_string,\n 'crmId' => $crmId,\n 'exception' => $crmException->getMessage(),\n ]);\n\n return null;\n }\n\n if ($sfOpportunity instanceof ArrayIterator) {\n return $this->importOpportunity($sfOpportunity->getItems());\n }\n\n return $this->importOpportunity($sfOpportunity);\n }\n\n /**\n * @throws HttpNotFoundException\n */\n private function importOpportunity($crmData): ?Opportunity\n {\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $account = null;\n if (empty($crmData['AccountId']) === false) {\n /** @var ?Account $account */\n $account = $this->config->accounts()\n ->where('crm_provider_id', (string) $crmData['AccountId'])\n ->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmData['AccountId']);\n }\n }\n\n $userId = $profile?->getUserId() ?? $account?->getUserId();\n if ($userId === null) {\n $this->logger->error('[Salesforce] | Skip import, no user_id found', [\n 'id' => $crmData['Id'],\n ]);\n\n return null;\n }\n\n /** @var ?Stage $stage */\n $stage = null;\n if (isset($crmData['StageName'])) {\n $stage = $this->config\n ->stages()\n ->where('name', $crmData['StageName'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->orderBy('is_selectable', 'DESC')\n ->orderBy('id')\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $crmData['StageName']);\n }\n }\n\n $recordType = null;\n if (empty($crmData['RecordTypeId']) === false) {\n /** @var ?RecordType $recordType */\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $crmData['RecordTypeId'])\n ->first();\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $closeDate = null;\n if (empty($crmData['CloseDate']) === false) {\n $closeDate = Carbon::parse($crmData['CloseDate'])->format('Y-m-d');\n }\n\n $valueFieldName = 'Amount';\n if ($this->config->opportunity_value_field_id) {\n $valueFieldName = $this->config->opportunityValueField->crm_provider_id;\n }\n\n $data = [\n 'team_id' => $this->team->id,\n 'account_id' => $account->id ?? null,\n 'user_id' => $userId,\n 'owner_id' => $crmData['OwnerId'] ?? null,\n 'name' => mb_strimwidth($crmData['Name'] ?? '', 0, 128),\n 'value' => $crmData[$valueFieldName],\n 'currency_code' => CurrencyFormatter::formatCode($crmData['CurrencyIsoCode'] ?? null),\n 'close_date' => $closeDate,\n 'is_closed' => $crmData['IsClosed'],\n 'is_won' => $crmData['IsWon'],\n 'stage_id' => $stage?->id ?? null,\n 'record_type_id' => $recordType->id ?? null,\n 'remotely_created_at' => $createdDate,\n 'probability' => $crmData['Probability'] ?? null,\n 'forecast_category' => $crmData['ForecastCategoryName'] ?? null,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->opportunities(), $crmData['Id']);\n\n // Do not allow locked DB tables & other errors\n // to interrupt the process of reverting the trashed opportunities\n try {\n /** @var Opportunity $opportunity */\n $opportunity = $this->config->opportunities()\n ->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n // import external fields into crm_field_data if present\n $crmFields = $this->getOpportunitySyncableFields();\n\n $this->importOpportunityCrmFieldData($crmData, $crmFields, $opportunity->id);\n\n $this->handleObjectDeletion($opportunity, $crmData);\n\n if ($opportunity->trashed()) {\n return null;\n }\n\n if ($opportunity->wasRecentlyCreated) {\n MatchActivitiesToNewOpportunity::dispatch($opportunity->getId());\n }\n\n return $opportunity;\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n $this->logger->error('[Salesforce] importOpportunity failure.', [\n 'crm_provider_id' => $crmData['Id'],\n 'team_id' => $this->team->id,\n 'exception' => $exception->getMessage(),\n ]);\n\n $this->handleEntityDeletionByProviderId($this->config->opportunities(), $crmData);\n }\n\n return null;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContacts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n $fields = $this->getAllFieldsAsArray('contact');\n if (\\in_array('Id', $fields, true) === false) {\n return $syncCount;\n }\n\n $query = '\n SELECT ' . rtrim(implode(',', $fields), ',') . '\n FROM Contact\n WHERE LastModifiedDate > :since\n ORDER BY LastModifiedDate ASC';\n\n try {\n $sfContacts = $this->queryHandler->query($query, [\n 'since' => $since->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($sfContacts as $sfContact) {\n // Only sync if previously imported.\n if ($this->hasContact($sfContact['Id'])) {\n $this->importContact($sfContact);\n $syncCount++;\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::CONTACT);\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContact(string $crmId): ?Contact\n {\n $fields = $this->getAllFieldsAsArray('contact');\n if (! in_array('Id', $fields, true)) {\n $this->logger->info('[Salesforce] Sync contact cancelled. Fields are not available.', [\n 'crmId' => $crmId,\n 'userId' => $this->profile->getUserId(),\n ]);\n\n return null;\n }\n\n $sfContact = $this->getRecord('Contact', $crmId, $fields);\n\n return $this->importContact($sfContact);\n }\n\n private function importContact($crmData): ?Contact\n {\n $account = null;\n // Contacts may not have accounts...\n if (isset($crmData['AccountId'])) {\n $account = $this->config->accounts()\n ->where('crm_provider_id', (string) $crmData['AccountId'])\n ->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmData['AccountId']);\n }\n }\n\n $countryCode = $crmData['MailingCountryCode'] ?? null;\n\n // Salesforce allows custom \"countries\" to be created. Disregard these.\n if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {\n $countryCode = null;\n }\n\n // If we have no country code, try to parse it from the country name.\n if ($countryCode === null && empty($crmData['MailingCountry']) === false) {\n $countryCode = $this->convertCountryNameToCode($crmData['MailingCountry']);\n\n if ($countryCode === null && $account) {\n $countryCode = $account->country_code;\n }\n }\n\n $ext = null;\n $parsedNumber = [];\n if (empty($crmData['Phone']) === false) {\n $number = Str::limit($crmData['Phone'], 25, '');\n $parsedNumber = parsePhoneNumber($countryCode, $number);\n\n if (empty($parsedNumber['ext']) === false) {\n $ext = Str::limit($parsedNumber['ext'], 10, '');\n }\n }\n\n $mobileNumber = null;\n if (empty($crmData['MobilePhone']) === false) {\n $mobileNumber = Str::limit(phone_e164($countryCode, $crmData['MobilePhone']), 25, '');\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $data = [\n 'team_id' => $this->team->id,\n 'account_id' => $account->id ?? null,\n 'user_id' => $profile?->user_id,\n 'owner_id' => $crmData['OwnerId'] ?? null,\n 'name' => ($crmData['Name'] ?? null) !== null ? mb_strimwidth($crmData['Name'], 0, 100) : '',\n 'title' => ($crmData['Title'] ?? null) !== null ? mb_strimwidth($crmData['Title'], 0, 128) : null,\n 'email' => ($crmData['Email'] ?? null) !== null ? mb_strimwidth($crmData['Email'], 0, 191) : null,\n 'country_code' => $countryCode,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'ext' => $ext,\n 'mobile_phone' => $mobileNumber,\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['Id'],\n modelType: Contact::class,\n fileName: $crmData['Id'],\n avatarText: $crmData['Name']\n ),\n 'remotely_created_at' => $createdDate,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->contacts(), $crmData['Id']);\n\n /** @var Contact $contact */\n $contact = $this->config->contacts()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n $this->handleObjectDeletion($contact, $crmData);\n\n if ($contact->trashed()) {\n return null;\n }\n\n return $contact;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n $fields = [\n 'InstanceName',\n 'OrganizationType',\n 'IsSandbox',\n ];\n\n $orgValues = $this->getRecord('Organization', $this->config->crm_provider_id, $fields);\n\n $edition = null;\n switch ($orgValues['OrganizationType']) {\n case 'Developer Edition':\n $edition = Configuration::EDITION_DEVELOPER;\n\n break;\n\n case 'Professional Edition':\n $edition = Configuration::EDITION_PROFESSIONAL;\n\n break;\n\n case 'Enterprise Edition':\n $edition = Configuration::EDITION_ENTERPRISE;\n\n break;\n }\n\n $this->config->edition = $edition;\n $this->config->instance = $orgValues['InstanceName'];\n\n // XXX: How can this state be possible?\n if ($this->config->version === null) {\n $this->config->version = Client::MIN_API_VERSION;\n }\n\n $installedVersion = $this->getInstalledAppVersion();\n if ($installedVersion !== null) {\n $installedVersion = (string) $this->getInstalledAppVersion();\n }\n\n $this->config->installed_app_version = $installedVersion;\n\n $this->config->save();\n }\n\n public function getInstalledAppVersion(): ?string\n {\n try {\n $query = '\n SELECT\n SubscriberPackageVersion.MajorVersion,\n SubscriberPackageVersion.MinorVersion,\n SubscriberPackageVersion.PatchVersion,\n SubscriberPackageVersion.BuildNumber\n FROM\n InstalledSubscriberPackage\n WHERE\n SubscriberPackageId = :packageId\n ';\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'packageId' => self::INSTALLED_PACKAGE_ID,\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n // Grab version number.\n $version = $sfField['SubscriberPackageVersion']['MajorVersion'] .\n $sfField['SubscriberPackageVersion']['MinorVersion'] .\n $sfField['SubscriberPackageVersion']['PatchVersion'] .\n $sfField['SubscriberPackageVersion']['BuildNumber'];\n } catch (\\Exception) {\n $version = null;\n }\n\n return $version;\n }\n\n /**\n * Store transcripts as note.\n *\n * @throws \\Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // For SF we also check if Log Notes is enabled.\n if ($this->profile->log_notes === Profile::LOG_NOTE_NONE) {\n return;\n }\n\n if ($activity->opportunity_id && $activity->prospect === null) {\n return;\n }\n\n try {\n $transcriptionData = $this->generateTranscription($activity);\n\n $noteMaxLength = $this->profile->log_notes === Profile::LOG_NOTE_ENHANCED\n ? self::ENHANCED_NOTE_MAX_LENGTH\n : self::CLASSIC_NOTE_MAX_LENGTH;\n\n $title = 'Transcript for ';\n $title .= $activity->title ?? $activity->activity_title;\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $body = mb_strimwidth($transcriptionData, 0, $noteMaxLength);\n\n if ($activity->opportunity_id) {\n $objectId = $activity->opportunity->crm_provider_id;\n } else {\n $objectId = $activity->prospect->crm_provider_id;\n }\n\n $noteId = $this->saveNote($title, $body, $objectId);\n\n // Store crm logged id in transcription.\n $transcription = $activity->getTranscription();\n $transcription->crm_activity_id = $noteId;\n $transcription->save();\n } catch (\\Exception $e) {\n \\Sentry::captureException($e);\n }\n }\n\n public function saveNote(string $title, string $body, string $objectId, ?NoteObject $noteObject = null): ?string\n {\n $noteId = null;\n\n try {\n if ($this->profile->log_notes === Profile::LOG_NOTE_ENHANCED) {\n $noteId = $this->buildEnhancedNote($title, $body, $objectId);\n } else {\n $noteId = $this->buildClassicNote($title, $body, $objectId);\n }\n } catch (HttpNotFoundException $exception) {\n // The profile not having access to create Enhanced Notes. Set their preference to Classic.\n if ($this->profile->log_notes === Profile::LOG_NOTE_ENHANCED) {\n $this->profile->update([\n 'log_notes' => Profile::LOG_NOTE_CLASSIC,\n ]);\n }\n }\n\n return $noteId;\n }\n\n /**\n * This is using the \"Enhanced\" Notes feature, NOT the \"Notes & Attachments\" feature being deprecated.\n *\n * @url https://salesforce.stackexchange.com/questions/104408/how-can-i-create-an-account-note-or-contact-note-via-api-that-is-visible-in-sale\n */\n private function buildEnhancedNote(string $title, string $body, string $objectId): string\n {\n // Decode stored entities, escape HTML (without quoting), then convert line breaks for Salesforce formatting\n $decodedBody = html_entity_decode($body, ENT_QUOTES | ENT_HTML5);\n $sanitizedBody = htmlspecialchars($decodedBody, ENT_NOQUOTES, 'UTF-8', false);\n $content = nl2br($sanitizedBody, false);\n $note = [\n 'OwnerId' => $this->profile->crm_provider_id,\n 'Title' => $title,\n 'Content' => base64_encode($content),\n ];\n\n $noteId = $this->createRecord('ContentNote', $note);\n\n $link = [\n 'ContentDocumentId' => $noteId,\n 'LinkedEntityId' => $objectId,\n 'ShareType' => 'I',\n ];\n\n $this->createRecord('ContentDocumentLink', $link);\n\n return $noteId;\n }\n\n private function buildClassicNote(string $title, string $body, string $objectId): string\n {\n if (in_array($this->parseObjectType($objectId), [Field::OBJECT_TASK, Field::OBJECT_EVENT])) {\n $this->logger->info('[Salesforce] Summary not sent', [\n 'profile_id' => $this->profile->id,\n 'objectId' => $objectId,\n 'reason' => 'Classical Note does not support Task/Event relation',\n ]);\n\n return '';\n }\n\n $titleTrimmed = null;\n\n if (mb_strlen($title) > 80) {\n $titleTrimmed = substr($title, 0, 77) . '...';\n }\n $payload = [\n 'OwnerId' => $this->profile->crm_provider_id,\n 'IsPrivate' => false,\n 'Title' => $titleTrimmed ?? $title,\n 'Body' => $titleTrimmed ? $title . PHP_EOL . $body : $body,\n 'ParentId' => $objectId,\n ];\n\n return $this->createRecord('Note', $payload);\n }\n\n /**\n * @inheritdoc\n */\n public function find(string $name, array $scopes): array\n {\n if ($this->profile === null) {\n return [];\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $limitValues = ['limit' => $this->limit, 'offset' => $this->offset];\n $sosl = $queryBuilder->buildFindQuery($name, $scopes, $limitValues);\n\n $this->logger->info('[Salesforce] Find prospects', [\n 'profile_id' => $this->profile->id,\n 'sosl_query' => $sosl,\n 'search_string' => $name,\n 'scopes' => $scopes,\n ]);\n\n $data = Cache::remember($this->profile->id . $sosl, self::CACHE_TTL, function () use ($sosl) {\n $data = [];\n\n try {\n // Hit remote API.\n $objects = $this->queryHandler->search($sosl);\n\n // Build mapped list.\n foreach ($objects as $object) {\n $type = strtolower($object['attributes']['type']);\n\n $record = [\n 'crmId' => $object['Id'],\n 'name' => $object['Name'],\n 'prospectType' => $type,\n 'phoneNumbers' => [],\n 'crmUrl' => $this->generateProviderUrl($object['Id'], $type),\n ];\n\n switch ($type) {\n case 'lead':\n if (empty($object['Company']) === false) {\n $record['organization'] = $object['Company'];\n }\n\n if (empty($object['Title']) === false) {\n $record['title'] = $object['Title'];\n }\n\n $stage = $this->config->stages()\n ->where('type', Stage::TYPE_LEAD)\n ->where('name', $object['Status'])\n ->first();\n\n // Lazy create the stage.\n if ($stage === null) {\n $stage = $this->importStages([Stage::TYPE_LEAD], $object['Status']);\n }\n\n if ($stage) {\n $record += [\n 'stage' => [\n 'id' => $stage->id_string,\n 'name' => $stage->name,\n ],\n ];\n }\n\n if (empty($object['RecordTypeId']) === false) {\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $object['RecordTypeId'])\n ->first();\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n }\n\n break;\n\n case 'account':\n if (empty($object['Industry']) === false) {\n $record['industry'] = $object['Industry'];\n $record['detailsLine'] = $object['Industry'];\n }\n if (! empty($object['PersonEmail'])) {\n $record['detailsLine'] = $object['PersonEmail'];\n }\n\n break;\n\n case 'contact':\n // For contacts, we should try and fetch their account name too.\n if ($object['AccountId']) {\n // Cheaper to get this locally.\n $account = $this->config->accounts()\n ->where('crm_provider_id', $object['AccountId'])\n ->first(['name']);\n\n if ($account) {\n $record['organization'] = $account->name;\n }\n }\n\n if (! empty($object['IsPersonAccount']) && $object['Email']) {\n $record['detailsLine'] = $object['Email'];\n } else {\n if (empty($object['Title']) === false) {\n $record['title'] = $object['Title'];\n }\n }\n\n break;\n }\n\n // Add phone numbers to record.\n if (empty($object['Phone']) === false && $object['Phone']) {\n $record['phoneNumbers'][] = [\n 'number' => $object['Phone'],\n 'nationalFormat' => phone_national($this->profile->user->country_code, $object['Phone']),\n 'type' => 'phone',\n ];\n }\n\n if (empty($object['MobilePhone']) === false && $object['MobilePhone']) {\n $record['phoneNumbers'][] = [\n 'number' => $object['MobilePhone'],\n 'nationalFormat' => phone_national(\n $this->profile->user->country_code,\n $object['MobilePhone']\n ),\n 'type' => 'mobile',\n ];\n }\n\n $data[] = $record;\n }\n } catch (NoResultsException $e) {\n $data = [];\n }\n\n return $data;\n });\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n\n if ($crmAccountId === null) {\n return $data;\n }\n\n if ($userId) {\n $profileRepository = app(ProfileRepository::class);\n $profile = $profileRepository->findProfileByUserId($this->config, $userId);\n\n $ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;\n }\n\n try {\n // Perhaps their profile has no opportunity permissions.\n if ($this->profile === null || $this->profile->opportunity_fields === null) {\n return $data;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $query = $queryBuilder->buildFindOpportunitiesQuery();\n\n $objects = $this->queryHandler->query($query, ['accountId' => $crmAccountId]);\n\n foreach ($objects as $object) {\n $record = [\n 'crmId' => $object['Id'],\n 'name' => $object['Name'],\n 'won' => $object['IsWon'],\n 'closed' => $object['IsClosed'],\n ];\n\n $valueFieldName = 'Amount';\n if ($this->config->opportunity_value_field_id) {\n $valueFieldName = $this->config->opportunityValueField->crm_provider_id;\n }\n\n if (empty($object[$valueFieldName]) === false) {\n $currency = $object['CurrencyIsoCode'] ?? $this->config->default_currency;\n $value = formatCurrency($object[$valueFieldName], $currency);\n\n $record += [\n 'value' => $value,\n ];\n }\n\n $stage = $this->config->stages()\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->where('name', $object['StageName'])\n ->first();\n\n // Lazy create the stage.\n if ($stage === null) {\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $object['StageName']);\n }\n\n $record += [\n 'stage' => [\n 'id' => $stage->id_string,\n 'name' => $stage->name,\n ],\n ];\n\n if (empty($object['RecordTypeId']) === false) {\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $object['RecordTypeId'])\n ->first();\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n }\n\n if ($ownerId && isset($object['OwnerId']) && $object['OwnerId'] === $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n } catch (NoResultsException $e) {\n return $data;\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n public function getContactRolesFromCrm(?Carbon $since = null): array\n {\n $roles = [];\n\n if ($this->profile === null) {\n return $roles;\n }\n\n $queryBuilder = app(QueryBuilder::class, ['profile' => $this->profile]);\n\n $query = $queryBuilder->buildGetContactRolesQuery($since);\n\n try {\n $objects = $this->queryHandler->query($query);\n\n foreach ($objects as $object) {\n $roles[] = [\n 'id' => $object['Id'],\n 'contactId' => $object['ContactId'],\n 'opportunityId' => $object['OpportunityId'],\n 'ownerId' => $object['Opportunity']['OwnerId'] ?? null,\n 'isPrimary' => $object['IsPrimary'],\n 'role' => $object['Role'],\n ];\n }\n } catch (NoResultsException) {\n // Just return an empty array.\n $this->logger->info('[Salesforce] No contact roles found', [\n 'since' => $since?->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n }\n\n return $roles;\n }\n\n public function syncContactRoles(Carbon $since): int\n {\n $contactRoleRepository = app(ContactRoleRepository::class);\n\n $crmContactRoles = $this->getContactRolesFromCrm(since: $since);\n $syncCount = 0;\n $contactRoles = [];\n\n foreach ($crmContactRoles as $crmContactRole) {\n $contactRoles[] = $this->importContactRole($crmContactRole);\n $syncCount++;\n }\n\n $contactRoleRepository->saveContactRoles($contactRoles);\n\n $this->syncRemotelyDeletedContactRoles();\n\n return $syncCount;\n }\n\n private function importContactRole(array $contactRole): array\n {\n $contact = $this->config->contacts()\n ->where('crm_provider_id', $contactRole['contactId'])\n ->first();\n\n if ($contact === null) {\n $contact = $this->syncContact($contactRole['contactId']);\n }\n\n $opportunity = $this->config->opportunities()\n ->where('crm_provider_id', $contactRole['opportunityId'])\n ->first();\n\n if ($opportunity === null) {\n $opportunity = $this->syncOpportunity($contactRole['opportunityId']);\n }\n\n $role = null;\n if (! empty($contactRole['role'])) {\n $role = mb_strimwidth($contactRole['role'], 0, 191);\n }\n\n return [\n 'crm_configuration_id' => $this->config->getId(),\n 'contact_id' => $contact->getId(),\n 'crm_provider_id' => $contactRole['id'],\n 'subject_type' => ContactRole::SUBJECT_TYPE_OPPORTUNITY,\n 'subject_id' => $opportunity->getId(),\n 'is_primary' => $contactRole['isPrimary'],\n 'role' => $role,\n ];\n }\n\n protected function syncRemotelyDeletedContactRoles(): bool\n {\n try {\n $deletedRemotely = $this->queryHandler->queryDeleted('OpportunityContactRole');\n } catch (NoResultsException $e) {\n return false;\n }\n\n $deletedOpportunities = $deletedRemotely->getResults();\n $deletedIds = array_column($deletedOpportunities, 'id');\n\n $contactRoleRepository = app(ContactRoleRepository::class);\n\n foreach (array_chunk($deletedIds, self::HARD_DELETE_CHUNK) as $chunk) {\n $contactRoleRepository->deleteContactRoles($chunk);\n\n $this->logger->info('[' . $this->getDisplayName() . '] Remotely deleted opportunities synced', [\n 'teamId' => $this->team->id_string,\n 'remotelyDeletedOpportunities' => $chunk,\n 'count' => count($chunk),\n ]);\n }\n\n return true;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = $objects = [];\n\n $hasWho = \\in_array($objectType, ['lead', 'contact']);\n $playbook = $this->getPlaybook($this->profile->user);\n $fields = array_merge(\n $this->profile->getFieldsAsArray(parent::OBJECT_TASK),\n $playbook && $playbook->activityField ? [$playbook->activityField->crm_provider_id] : []\n );\n\n // Query should default to any open call for that user.\n $query = '\n SELECT ' . implode(',', array_unique($fields)) . '\n FROM Task\n WHERE OwnerId = :ownerId\n AND IsArchived = false\n AND IsDeleted = false\n AND IsClosed = false\n AND (';\n\n if ($objectType === 'account') {\n // This covers tasks tied to a related contact or opportunity too.\n $query .= '\n AccountId = :accountId';\n }\n\n if ($hasWho) {\n $query .= '\n WhoId = :whoId';\n\n // If we are also going to check on a specific opportunity, set that up.\n if ($opportunityId) {\n $query .= ' OR WhatId = :whatId';\n }\n }\n\n $query .= ' ) ORDER BY LastModifiedDate DESC';\n\n try {\n $objects = $this->queryHandler->query($query, [\n 'ownerId' => $this->profile->crm_provider_id,\n 'whoId' => $objectId,\n 'whatId' => $opportunityId,\n 'accountId' => $objectId,\n ]);\n } catch (NoResultsException $e) {\n return $data;\n } finally {\n $this->logger->debug(sprintf('[Salesforce] Found %s tasks for query \"%s\"', count($objects), $query));\n }\n\n foreach ($objects as $object) {\n $dueDate = $object['ActivityDate'] ? Carbon::parse($object['ActivityDate'])->toIso8601String() : null;\n $data[] = [\n 'crmId' => $object['Id'],\n 'subject' => $object['Subject'],\n 'due' => $dueDate,\n 'type' => $object[$playbook->activityField->crm_provider_id],\n ];\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getEvents(string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = $objects = [];\n $user = $this->profile?->user;\n if ($this->profile === null || $user === null) {\n return $data;\n }\n\n $hasWho = \\in_array($objectType, ['lead', 'contact']);\n $playbook = $this->getPlaybook($user);\n $fields = array_merge(\n $this->profile->getFieldsAsArray(parent::OBJECT_EVENT),\n $playbook && $playbook->activityField ? [$playbook->activityField->crm_provider_id] : []\n );\n\n // Query should default to any event starting in the last week and ending up until today owned by the user.\n $query = '\n SELECT ' . implode(',', array_unique($fields)) . '\n FROM Event\n WHERE OwnerId = :ownerId\n AND IsArchived = false\n AND IsAllDayEvent = false\n AND StartDateTime >= LAST_N_DAYS:7\n AND EndDateTime <= TODAY\n AND (';\n\n if ($objectType === 'account') {\n // This covers events tied to a related contact or opportunity too.\n $query .= '\n AccountId = :accountId';\n }\n\n if ($hasWho) {\n $query .= '\n WhoId = :whoId';\n\n // If we are also going to check on a specific opportunity, set that up.\n if ($opportunityId) {\n $query .= ' OR WhatId = :whatId';\n }\n }\n\n $query .= ' ) ORDER BY LastModifiedDate DESC';\n\n try {\n $objects = $this->queryHandler->query($query, [\n 'ownerId' => $this->profile->crm_provider_id,\n 'whoId' => $objectId,\n 'whatId' => $opportunityId,\n 'accountId' => $objectId,\n ]);\n } catch (NoResultsException $e) {\n return $data;\n } finally {\n $this->logger->debug(sprintf('[Salesforce] Found %s tasks for query \"%s\"', count($objects), $query));\n }\n\n foreach ($objects as $object) {\n $dueDate = $object['StartDateTime'] ? Carbon::parse($object['StartDateTime'])->toIso8601String() : null;\n\n $data[] = [\n 'crmId' => $object['Id'],\n 'subject' => $object['Subject'],\n 'due' => $dueDate,\n 'type' => $object[$playbook->activityField->crm_provider_id],\n ];\n }\n\n return $data;\n }\n\n /**\n * Try to find CRM Objects using email address\n *\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchExactlyByEmail(string $email, ?int $userId = null): ?array\n {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $sosl = $queryBuilder->buildMatchByQuery($email, Field::TYPE_EMAIL);\n if ($sosl === null) {\n return null;\n }\n\n try {\n $objects = $this->queryHandler->search($sosl);\n $objects = $this->queryHandler->prioritiseResults(\n $objects,\n $email,\n QueryHandler::PRIORITISE_EMAIL\n );\n\n $data = $this->convertCrmData($objects, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (NoResultsException $e) {\n // Try the account next.\n if ($this->profile->account_fields === null) {\n return null;\n }\n }\n\n return null;\n }\n\n public function getDomain(string $email): ?string\n {\n // SF improved search - strip the domain extension, min domain name length 4\n return $this->getCompanyNameFromEmail(email: $email, minNameLength: 4);\n }\n\n /**\n * Try to find CRM objects using domain name of the email address\n *\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByDomain(string $domain, ?int $userId = null): ?array\n {\n $companyName = $domain;\n\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $sosl = $queryBuilder->buildMatchByDomainQuery($companyName);\n\n try {\n $objects = $this->queryHandler->search($sosl);\n\n $data = $this->convertCrmData($objects, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (NoResultsException) {\n return null;\n }\n }\n\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n // Don't bother looking up numbers that are masked.\n if (str_contains($phone, '**')) {\n return null;\n }\n\n if ($this->isPhoneNumberOfTeamMember($phone)) {\n return null;\n }\n\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $phoneNational = phone_national(null, $phone) ?? '';\n $possiblePhoneFormats = collect([\n preg_replace('/\\D/', '', ltrim($phone, '0+')),\n preg_replace('/\\D/', '', $phoneNational),\n formatDashPhoneNumber($phone),\n $phoneNational,\n ])\n ->filter() // Removes null and empty strings\n ->unique()\n ->values();\n\n foreach ($possiblePhoneFormats as $phone) {\n $sosl = $queryBuilder->buildMatchByQuery($phone, Field::TYPE_PHONE);\n if ($sosl === null) {\n continue;\n }\n\n try {\n $objects = $this->queryHandler->search($sosl);\n $objects = $this->queryHandler->prioritiseResults(\n $objects,\n $phone,\n QueryHandler::PRIORITISE_PHONE\n );\n\n return $this->convertCrmData($objects, $userId);\n } catch (NoResultsException) {\n continue;\n }\n }\n\n return null;\n }\n\n private function isPhoneNumberOfTeamMember(string $phone): bool\n {\n $teamRepository = app(TeamRepository::class);\n $user = $teamRepository->findTeamMemberByPhone($this->team, $phone);\n\n if ($user instanceof User) {\n return true;\n }\n\n return false;\n }\n\n protected function getCacheKey(string $object, ?int $userId = null): ?string\n {\n $key = $this->profile->id . $object;\n $keySuffix = $this->getOwnerKeySuffix($userId);\n\n return $key . $keySuffix;\n }\n\n private function getOwnerKeySuffix(?int $userId = null): string\n {\n return $userId === null ? '' : (string) $userId;\n }\n\n /** Determine the CRM Objects which represent the call activity. */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n if ($this->profile === null) {\n return null;\n }\n\n $cacheKey = $this->getCacheKey($name, $userId);\n\n $result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $sosl = $queryBuilder->buildMatchByQuery($name, 'name');\n if ($sosl === null) {\n return false;\n }\n\n try {\n $objects = $this->queryHandler->search($sosl);\n } catch (NoResultsException $e) {\n return false;\n }\n\n $objects = $this->queryHandler->prioritiseResults(\n $objects,\n $name,\n QueryHandler::PRIORITISE_NAME\n );\n\n $data = $this->convertCrmData($objects, $userId);\n\n return (! empty(array_filter($data))) ? $data : false;\n });\n\n return is_array($result) ? $result : null;\n }\n\n /**\n * @return array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n protected function convertCrmData(QueryIterator $objects, ?int $userId = null): array\n {\n $lead = null;\n $contact = null;\n $opportunity = null;\n $account = null;\n $stage = null;\n $countryCode = null;\n\n if ($objects->count() > 0) {\n $object = $objects->current();\n\n if ($object['attributes']['type'] === 'Lead') {\n $lead = $this->importLead($object);\n\n // Lead might not be imported if the Stage is null for example.\n if ($lead) {\n $countryCode = $lead->country_code;\n $stage = $lead->stage;\n }\n } else {\n if ($object['attributes']['type'] === 'Contact') {\n $contact = $this->importContact($object);\n $account = $contact->account;\n } else {\n $account = $this->importAccount($object);\n }\n\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account) {\n $countryCode = $account->country_code;\n }\n\n try {\n $sfOpportunities = $this->findOpportunities(\n $account?->getCrmProviderId(),\n $contact?->getCrmProviderId(),\n $userId\n );\n\n // Take the first opportunity, which will be ordered as priority based on their settings.\n if (! empty($sfOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($sfOpportunities[0]['crmId']);\n $stage = $opportunity?->stage;\n }\n } catch (Exception) {\n // Nothing to see here.\n }\n }\n }\n\n return [\n $lead,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n /**\n * @inheritdoc\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n if ($stage->type === Stage::TYPE_LEAD) {\n $objectType = 'Lead';\n $objectId = $crmObject->crm_provider_id;\n $objectStageType = 'Status';\n } else {\n $objectType = 'Opportunity';\n $objectId = $crmObject->crm_provider_id;\n $objectStageType = 'StageName';\n }\n\n $headers = [];\n if ($this->config->trigger_assignment_rules === false) {\n // @see: https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/headers_autoassign.htm\n $headers = [\n 'Sforce-Auto-Assign' => 'false',\n ];\n }\n\n $this->updateRecord($objectType, $objectId, [$objectStageType => $stage->name], $headers);\n }\n\n public function parseObjectType(string $objectId): string\n {\n if (Str::startsWith($objectId, '001')) {\n return 'account';\n }\n\n if (Str::startsWith($objectId, '003')) {\n return 'contact';\n }\n\n if (Str::startsWith($objectId, '00Q')) {\n return 'lead';\n }\n\n if (Str::startsWith($objectId, '006')) {\n return 'opportunity';\n }\n\n if (Str::startsWith($objectId, '00U')) {\n return 'event';\n }\n\n if (Str::startsWith($objectId, '00T')) {\n return 'task';\n }\n\n throw new \\InvalidArgumentException('Unsupported Object Type');\n }\n\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, ['profile' => $this->profile]);\n $query = $queryBuilder->buildGetUsersQuery($userToSearch);\n\n try {\n $salesforceUsers = $this->queryHandler->query($query, [\n 'active' => true,\n ]);\n } catch (NoResultsException $e) {\n $this->logger->info('[Salesforce] Sync Profiles. No users found', [\n 'query' => $query,\n 'error' => $e->getMessage(),\n ]);\n\n return null;\n }\n\n $teamRepository = app(TeamRepository::class);\n $customRules = $this->getCustomProfileRules($teamRepository);\n\n foreach ($salesforceUsers as $crmUser) {\n if ($crmUser['Email'] === null) {\n continue;\n }\n\n if (! $this->customProfileValidation($crmUser, $customRules)) {\n continue;\n }\n\n $user = $teamRepository->findActiveTeamMemberByEmail($this->team, $crmUser['Email']);\n\n if (! $user instanceof User) {\n continue;\n }\n\n $edition = $crmUser['UserPreferencesLightningExperiencePreferred']\n ? Profile::EDITION_LIGHTNING\n : Profile::EDITION_CLASSIC;\n\n $profileRepository = app(ProfileRepository::class);\n $profile = $profileRepository->updateOrCreateProfile(\n $user,\n [\n 'crm_configuration_id' => $this->config->getId(),\n 'crm_provider_id' => $crmUser['Id'],\n ],\n [\n 'user_id' => $user->getId(),\n 'edition' => $edition,\n 'has_external_cti' => ! empty($crmUser['CallCenterId']),\n 'crm_profile_id' => $crmUser['ProfileId'],\n ]\n );\n\n if ($userToSearch instanceof User && $userToSearch->getId() === $user->getId()) {\n return $profile;\n }\n }\n\n // Clean up inactive profiles\n try {\n $this->archiveInactiveProfiles();\n } catch (\\Exception $e) {\n $this->logger->warning('[Salesforce] Profile archiving failed', [\n 'teamId' => $this->team->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $url = null;\n\n // For Salesforce it's easy, we just point every object to the apex domain and they handle it.\n switch ($objectType) {\n case 'lead':\n case 'account':\n case 'contact':\n case 'opportunity':\n case 'task':\n case 'event':\n case 'activity':\n\n $url = $this->config->crm_base_url . '/' . $providerId;\n\n break;\n }\n\n return $url;\n }\n\n public function buildTaskSearchFields(): array\n {\n return ['Id', 'WhoId', 'WhatId', 'AccountId'];\n }\n\n public function getTaskByFilterConditions(\n array $fields,\n array $filters,\n bool $bulkSearch = false,\n bool $strictFilters = true\n ): ?array {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $query = $queryBuilder->buildSearchTaskQuery($fields, $filters, $bulkSearch, $strictFilters);\n\n try {\n if (! $bulkSearch) {\n $objects = $this->queryHandler->query($query, $filters);\n if ($objects->count() === 1) {\n return $objects->current();\n }\n }\n\n if ($bulkSearch) {\n $objects = $this->queryHandler->query($query);\n $records = [];\n foreach ($objects as $record) {\n $key = $record[end($fields)];\n $records[$key] = $record;\n }\n\n return $records;\n }\n } catch (\\Exception $e) {\n $this->logger->info('[Salesforce] Failed to execute query', [\n 'query' => $query,\n 'error' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function mapCrmObjects(array $task): array\n {\n $activityData = [];\n\n if (! empty($task['WhoId'])) {\n $type = $this->parseObjectType($task['WhoId']);\n $activityData[$type] = $task['WhoId'];\n }\n if (! empty($task['AccountId'])) {\n $activityData['account'] = $task['AccountId'];\n }\n if (! empty($task['WhatId'])) {\n $activityData['opportunity'] = $task['WhatId'];\n }\n\n return $activityData;\n }\n\n /**\n * Get SF task by Outreach call id.\n */\n public function getTaskByFilter(\n string $activityFieldType,\n array $filters,\n string $operator = '=',\n array $additionalFields = []\n ): ?array {\n $data = [];\n\n try {\n // Default (base) fields.\n $fields = ['Id', 'Subject', 'Description', 'ActivityDate', 'WhoId', 'WhatId', $activityFieldType];\n\n foreach ($additionalFields as $additionalField) {\n $fields[] = $additionalField->crm_provider_id;\n }\n\n $fields = array_unique($fields);\n\n // Find task with the same Outreach id as the call id.\n $query = 'SELECT ' . implode(',', $fields) . '\n FROM Task\n WHERE IsArchived = false AND IsDeleted = false';\n\n foreach ($filters as $key => $value) {\n $key = preg_quote($key, '/');\n $key = str_replace(['\\'', '\"'], '', $key);\n // Prepare the substitution.\n $strKey = \":$key\";\n\n $query .= \" AND $key $operator $strKey\";\n }\n\n $query .= ' ORDER BY LastModifiedDate DESC LIMIT 1';\n\n $objects = $this->queryHandler->query($query, $filters);\n\n // There should be only one task related to this call if any.\n if ($objects->count() === 1) {\n $object = $objects->current();\n\n $dueDate = $object['ActivityDate'] ? Carbon::parse($object['ActivityDate'])->toIso8601String() : null;\n\n $data = array_merge($object, [\n 'crmId' => $object['Id'],\n 'subject' => $object['Subject'],\n 'summary' => $object['Description'],\n 'due' => $dueDate,\n 'Type' => $object[$activityFieldType],\n ]);\n }\n } catch (NoResultsException $e) {\n // Filters don't match any records.\n } catch (ServiceUnavailableException $serviceUnavailableException) {\n // Service cannot be queried. We should probably log this.\n }\n\n return $data;\n }\n\n /**\n * Get Salesforce fields including datetime fields\n *\n * @param $objectType\n */\n private function getAllFieldsAsArray($objectType): array\n {\n $basicFields = [];\n // Not all users have access to all object fields.\n if ($this->profile->{$objectType . '_fields'}) {\n $basicFields = explode(',', $this->profile->{$objectType . '_fields'});\n }\n\n $extraFields = [\n 'CreatedDate',\n 'LastModifiedDate',\n 'IsDeleted',\n ];\n\n if ($objectType === self::OBJECT_OPPORTUNITY\n && $this->config->opportunity_value_field_id\n && ! in_array($this->config->opportunityValueField->crm_provider_id, $basicFields)\n ) {\n $extraFields[] = $this->config->opportunityValueField->crm_provider_id;\n }\n\n return array_unique(array_merge($basicFields, $extraFields));\n }\n\n /**\n * Generate transcription for activity description.\n */\n private function generateTranscription(Activity $activity): string\n {\n if (! ($this->config->store_transcript)) {\n // If sending transcription to activity toggle is disabled\n return '';\n }\n\n return $this->transcriptionService\n ->findTranscriptionByActivity($activity)\n ->map(static function (array $transcriptionSegment): string {\n return $transcriptionSegment['formattedStartsAt'] . ' | ' . $transcriptionSegment['transcript'];\n })\n ->implode(PHP_EOL);\n }\n\n /**\n * Find related Salesforce event based on activity data\n *\n * @return array<string>\n */\n public function fetchRelatedActivity(Activity $activity): array\n {\n $this->logger->info('[Salesforce] Searching for related activity', [\n 'activityId' => $activity->getUuid(),\n 'ownerId' => $this->profile?->crm_provider_id,\n ]);\n\n $sfEvent = $this->fetchRelatedEvent($activity);\n if (empty($sfEvent)) {\n $this->logger->info('[Salesforce] No related activity found', [\n 'activityId' => $activity->getUuid(),\n 'ownerId' => $this->profile?->crm_provider_id,\n 'account' => $activity->hasAccount()\n ? $activity->getAccount()->getCrmProviderId()\n : null,\n ]);\n\n return [];\n }\n\n return $sfEvent;\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n if ($activity->isTypeConference() === false) {\n return null;\n }\n\n if ($activity->hasActualStartTime() === false && $activity->hasScheduledStartTime() === false) {\n return null;\n }\n\n if (! $activity->hasProspect()) {\n $this->logger->info('[Salesforce] Skip look up, Activity not linked to Lead, Contact or Account', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n $playbook = $this->getPlaybook($activity->getUser());\n if ($playbook !== null && $playbook->getActivityType() === Playbook::ACTIVITY_TYPE_TASK) {\n $this->logger->info('[Salesforce] Skip auto-sync for task-based playbook', [\n 'activityUuid' => $activity->getUuid(),\n 'playbookId' => $playbook->getId(),\n 'playbookType' => $playbook->getActivityType(),\n ]);\n\n return null;\n }\n\n try {\n $sfEvent = $this->fetchRelatedActivity($activity);\n if (empty($sfEvent)) {\n return null;\n }\n\n [$activityField, $activityType] = $this->resolveActivityTypeFromEvent($activity, $sfEvent);\n\n $this->logger->info('[Salesforce] Found related activity', [\n 'activityId' => $activity->getUuid(),\n 'sfEvent' => $sfEvent['Id'],\n 'activityFieldName' => $activityField,\n 'crmActivityType' => ($activityField !== null && isset($sfEvent[$activityField]))\n ? $sfEvent[$activityField]\n : null,\n 'activityType' => $activityType,\n ]);\n\n $userId = $this->findRelatedActivityUserId($activity, $sfEvent);\n\n if ($activity->getUserId() !== $userId) {\n $this->logger->info('[Salesforce] Updating meeting owner', [\n 'activityId' => $activity->getUuid(),\n 'oldUserId' => $activity->getUserId(),\n 'newUserId' => $userId,\n ]);\n }\n\n $this->updateSfEventDescription($activity, $sfEvent);\n\n $activity->update([\n 'user_id' => $userId,\n 'crm_provider_id' => $sfEvent['Id'],\n 'playbook_category_id' => $activityType->id ?? $activity->getCategory()?->getId(),\n ]);\n\n $this->logger->info('[Salesforce] Activity updated', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return $activity;\n } catch (\\Exception $exception) {\n \\Sentry::captureException($exception);\n\n throw $exception;\n }\n }\n\n /**\n * @param array<string, mixed> $sfEvent\n *\n * @return array{0: string|null, 1: mixed}\n */\n private function resolveActivityTypeFromEvent(Activity $activity, array $sfEvent): array\n {\n $activityField = $this->getActivityFieldName($activity);\n $activityType = null;\n\n if ($activityField !== null && ! empty($sfEvent[$activityField])) {\n $playbook = $this->getPlaybook($activity->getUser());\n $activityType = $this->getPlaybookCategory($playbook, strval($sfEvent[$activityField]));\n }\n\n return [$activityField, $activityType];\n }\n\n /**\n * @param array<string> $sfEvent\n */\n private function findRelatedActivityUserId(Activity $activity, array $sfEvent): int\n {\n $userId = $activity->getUserId();\n\n if (empty($sfEvent['OwnerId']) === false) {\n $profile = $this\n ->config\n ->profiles()\n ->where('crm_provider_id', $sfEvent['OwnerId'])\n ->get()\n ->filter(static function (Profile $profile) use ($activity): bool {\n if (! $activity->isTypeConference()) {\n return ! empty($profile->user) ? $profile->user->isStatusActive() : false;\n }\n\n $participants = $activity->getParticipants();\n\n return ! empty($profile->user)\n ? $profile->user->isStatusActive()\n && $profile->user->hasPermission(PermissionEnum::RECORD_MEETING)\n && $participants->contains('user_id', $profile->user_id)\n : false;\n })\n ->first();\n\n if ($profile) {\n $userId = $profile->user_id;\n }\n }\n\n return $userId;\n }\n\n /**\n * @param array<string, mixed> $sfEvent\n */\n private function updateSfEventDescription(Activity $activity, array $sfEvent): void\n {\n try {\n if (str_contains($sfEvent['Description'], $activity->id_string)) {\n return;\n }\n\n $payload = [\n 'Description' => $sfEvent['Description']\n . PHP_EOL\n . PHP_EOL\n . (new DecorateActivity())->generateDescription($activity),\n ];\n\n $this->logger->info('[Salesforce] Update record', [\n 'activityId' => $activity->getUuid(),\n 'sfEvent' => $sfEvent['Id'],\n 'payload' => $payload,\n ]);\n\n $payload = array_merge(\n $payload,\n $this->payloadBuilder->fetchCustomFieldData($activity, Field::OBJECT_EVENT)\n );\n\n $this->updateRecord('Event', $sfEvent['Id'], $payload);\n } catch (\\Exception) {\n $this->logger->error('[Salesforce] Failed to update record', [\n 'activityUuid' => $activity->getUuid(),\n 'sfEvent' => $sfEvent['Id'],\n ]);\n }\n }\n\n /**\n * Returns the most recently modified Event within time range (if any).\n *\n * @return array|null An Event record from Salesforce.\n */\n private function fetchRelatedEvent(Activity $activity): ?array\n {\n $ownerId = $this->profile?->crm_provider_id;\n if ($ownerId === null) {\n return [];\n }\n\n /** @var ?Carbon $from */\n /** @var ?Carbon $to */\n [$from, $to] = $this->getFromToDates($activity);\n\n try {\n $whoId = null;\n $hasWho = $activity->lead_id || $activity->contact_id;\n if ($hasWho) {\n $whoId = $activity->hasLead()\n ? $activity->getLead()->crm_provider_id\n : $activity->getContact()->crm_provider_id;\n }\n\n if ($hasWho === false && $activity->account_id === null) {\n return null;\n }\n\n $query = $this->buildFetchRelatedEventQuery($activity);\n\n $objects = $this->queryHandler->query($query, [\n 'ownerId' => $ownerId,\n 'whoId' => $whoId,\n 'whatId' => $activity->hasOpportunity() ? $activity->getOpportunity()->crm_provider_id : null,\n 'accountId' => $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null,\n 'from' => $from?->format('Y-m-d\\TH:i:s\\Z'),\n 'to' => $to?->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($objects as $object) {\n return $object;\n }\n } catch (NoResultsException $e) {\n return [];\n }\n\n return [];\n }\n\n private function getFromToDates(Activity $activity): array\n {\n $from = null;\n $to = null;\n\n /** @var ?CalendarEvent $calendarEvent */\n $calendarEvent = $activity->calendarEvent()->first();\n if ($calendarEvent !== null) {\n $from = $calendarEvent->getStartTime();\n $to = $calendarEvent->getEndTime();\n }\n\n // For non-calendar imported activities\n // Also double check if calendar event dates could be null?\n // If null use what we've got so far\n if ($from === null || $to === null) {\n $from = $activity->hasScheduledStartTime()\n ? $activity->getScheduledStartTime()\n : $activity->getActualStartTime();\n $to = $activity->hasScheduledEndTime()\n ? $activity->getScheduledEndTime()->addMinutes(15)\n : $activity->getActualEndTime();\n }\n\n return [$from, $to];\n }\n\n /**\n * Determines the appropriate activity field name for querying Salesforce events.\n *\n * This method follows a hierarchy to determine the field name:\n * 1. Uses the playbook's activity field if it exists and is in the profile's accessible fields\n * 2. Falls back to the default activity field if the profile has no event fields configured\n * 3. Returns null if no suitable field is found\n *\n * @param Activity $activity The activity to determine the field for\n *\n * @return string|null The field name to use in queries, or null if none is available\n */\n private function getActivityFieldName(Activity $activity): ?string\n {\n if ($this->profile === null) {\n $this->logger->warning('[Salesforce] Cannot determine activity field - profile not found', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n $profileEventFields = $this->profile->getFieldsAsArray('event');\n\n if (empty($profileEventFields)) {\n $defaultActivityField = $this->getDefaultActivityField(Field::OBJECT_EVENT);\n $defaultFieldName = $defaultActivityField?->getAttribute('crm_provider_id');\n // Profile not yet synced — fall back to the default activity field.\n // There is a small chance that the profile won't have Default Activity Type field access\n // in which case the query will fail.\n // This is however an edge case and should be reviewed for profile sync issues.\n Sentry::withScope(function (\\Sentry\\State\\Scope $scope) use ($defaultFieldName): void {\n $scope->setContext('details', [\n 'profileId' => $this->profile->id,\n 'defaultField' => $defaultFieldName,\n ]);\n Sentry::captureMessage(\n '[Salesforce] Profile event fields empty, falling back to default activity field.',\n \\Sentry\\Severity::warning()\n );\n });\n\n return $defaultFieldName;\n }\n\n $playbook = $this->getPlaybook($activity->getUser());\n\n if (! is_null($playbook) && ! is_null($playbook->getActivityField())) {\n $playbookFieldName = $playbook->getActivityField()->getAttribute('crm_provider_id');\n\n if (in_array($playbookFieldName, $profileEventFields, true)) {\n return $playbookFieldName;\n }\n\n $this->logger->warning('[Salesforce] Playbook activity field not found in profile fields', [\n 'activityId' => $activity->getUuid(),\n 'playbookField' => $playbookFieldName,\n 'profileId' => $this->profile->id,\n ]);\n }\n\n return null;\n }\n\n private function buildFetchRelatedEventQuery(Activity $activity): string\n {\n $hasWho = $activity->lead_id || $activity->contact_id;\n\n $activityFieldName = $this->getActivityFieldName($activity);\n $fields = array_filter(['Id', 'Description', 'OwnerId', $activityFieldName]);\n\n $ownerCondition = '(OwnerId = :ownerId OR CreatedById = :ownerId)';\n\n $query = '\n SELECT ' . implode(',', $fields) . '\n FROM Event\n WHERE ' . $ownerCondition . '\n AND IsArchived = false\n AND IsAllDayEvent = false\n AND StartDateTime >= :from\n AND EndDateTime <= :to\n AND (';\n\n $operator = '';\n if ($activity->account_id) {\n // This covers events tied to a related contact or opportunity too.\n $query .= 'AccountId = :accountId';\n\n $operator = ' OR ';\n }\n\n if ($hasWho) {\n $query .= $operator . 'WhoId = :whoId';\n\n // If we are also going to check on a specific opportunity, set that up.\n if ($activity->opportunity_id) {\n $query .= ' OR WhatId = :whatId';\n }\n }\n\n $query .= ') ORDER BY LastModifiedDate DESC';\n\n return $query;\n }\n\n public function fetchProspect(array $task): array\n {\n $lead = $account = $opportunity = $contact = $stage = $countryCode = null;\n $externalId = $task['WhoId'] ?? null;\n\n // Lead or Contact\n if ($externalId) {\n try {\n [$lead, $account, $opportunity, $contact, $stage, $countryCode] = $this->parseRecords($externalId);\n } catch (\\InvalidArgumentException $exception) {\n // Invalid object type.\n }\n }\n\n // If we happen to know the opportunity or account from the Task, figure that out.\n if (empty($task['WhatId']) === false) {\n // WhatId could be either Account ID or Opportunity ID.\n // If WhatId is Opportunity ID, get the opportunity and stage from the CRM.\n try {\n [, $account, $opportunity, , $stage, ] = $this->parseRecords($task['WhatId']);\n } catch (\\InvalidArgumentException $exception) {\n // Invalid object type.\n }\n }\n\n return [$lead, $account, $opportunity, $contact, $stage, $countryCode];\n }\n\n /**\n * Save activity transcription summary as note\n */\n public function saveTranscriptionSummaryAsNote(\n ActivityContract $activity,\n string $title,\n string $body,\n ?string $objectId,\n ?NoteObject $noteObject = null,\n ): ?string {\n return $this->saveNote($title, $body, (string) $objectId);\n }\n\n public function getObjectByFilterConditions(string $objectType, array $fields, array $filters): ?array\n {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $query = $queryBuilder->buildObjectSearchQuery($objectType, $fields, $filters);\n\n try {\n $objects = $this->queryHandler->query($query, $filters);\n if ($objects->count() === 1) {\n return $objects->current();\n }\n } catch (\\Exception $e) {\n $this->logger->info('[Salesforce] Failed to execute query', [\n 'query' => $query,\n 'error' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n private function getCustomProfileRules(TeamRepository $teamRepository): array\n {\n $teamSettings = $teamRepository->getTeamSetting($this->team, 'custom_profile_validation');\n\n if ($teamSettings instanceof TeamSettings && $teamSettings->getValueType() === 'array') {\n $customRules = json_decode($teamSettings->getValue(), true);\n if (is_array($customRules)) {\n return $customRules;\n }\n }\n\n return [];\n }\n\n private function customProfileValidation(array $crmUser, array $customRules): bool\n {\n foreach ($customRules as $customRule) {\n if ($crmUser[$customRule['field']] !== $customRule['value']) {\n return false;\n }\n }\n\n return true;\n }\n\n /**\n * When syncing Contact / Lead / Account / Opportunity / Stage crm entities,\n * validate and restore locally trashed objects,\n * before updating them. Objects are identified by CrmProviderId\n */\n private function restoreAnyTrashedEntity(HasMany $targetEntity, string $crmProviderId): void\n {\n $recordExists = $targetEntity->withTrashed()->where(['crm_provider_id' => $crmProviderId])->first();\n if ($recordExists && $recordExists->trashed()) {\n $recordExists->restore();\n }\n }\n\n #[\\Override] public function supportsNotes(): bool\n {\n return true;\n }\n\n private function getOwnerProfile(?string $ownerId): ?Profile\n {\n if ($ownerId === null) {\n return null;\n }\n\n return $this->config->profiles()\n ->where('crm_provider_id', $ownerId)\n ->first();\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
-6057013493270297375
|
-7851939513083163707
|
typing_pause
|
accessibility
|
NULL
|
Diff
Changelist:
Changelist:
Changes
Show Diff
Rol Diff
Changelist:
Changelist:
Changes
Show Diff
Rollback...
Refresh
Group By
Expand All
Collapse All
1 modified
JY-
Commit Message
Commit Message History
Git
Author:
Author:
Amend commit
Sign-off commit
Create extra commit with file movements
Commit Checks
Update copyright
Reformat code
Rearrange code
Optimize imports
Cleanup
Choose profile
Check malicious dependencies
Run external formatter
Configure
Perform SonarQube for IDE analysis
Find unused view files
Run Git hooks
Advanced Commit Checks
Analyze code
Choose profile
Check TODO
Configure
After Commit
Upload files to:
Upload files to:
<None>
Always use selected server or group of servers
Previous Difference
Next Difference
Jump to Source
Compare Previous File
Compare Next File
Go to Changed File…
Side-by-side viewer
Do not ignore
Highlight words
Collapse Unchanged Fragments
Synchronize Scrolling
Disable Editing
Settings
Help
4 differences
1aee7aad
<?php
namespace Jiminny\Services\Crm\Salesforce;
use Carbon\Carbon;
use Exception;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Events\Dispatcher;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Jiminny\Component\Country\CountriesMap;
use Jiminny\Contracts\Acl\PermissionEnum;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Contracts\Services\Crm\FetchRelatedActivityInterface;
use Jiminny\Contracts\Services\Crm\ImportsBusinessProcessesInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\SalesforceBatchSyncInterface;
use Jiminny\Contracts\Services\Crm\Provider\SalesforceInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\RemoteNoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SearchTaskInterface;
use Jiminny\Contracts\Services\Crm\SendSummaryToCrmInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SupportsObjectTypeParseInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmProfileRecordTypesInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Enums\CrmObject;
use Jiminny\Events\Activities\Crm\LeadConverted;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpBadRequestException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Exceptions\NoResultsException;
use Jiminny\Exceptions\ServiceUnavailableException;
use Jiminny\Jobs\Crm\NoteObject;
use Jiminny\Jobs\Crm\MatchActivitiesToNewOpportunity;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Calendar\CalendarEvent;
use Jiminny\Models\Contact;
use Jiminny\Models\Contracts\ActivityContract;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Models\Crm\ContactRole;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Crm\RecordType;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Playbook;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\TeamSettings;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\ContactRoleRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\Crm\RecordTypeFieldValuesRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Helpers\ArrayIterator;
use Jiminny\Services\Crm\MatchDomainByEmailInterface;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Services\Crm\ResolveCompanyNameByEmailTrait;
use Jiminny\Services\Crm\Salesforce\Fields\FieldHelper;
use Jiminny\Services\Crm\Salesforce\Fields\FieldTypeConverter;
use Jiminny\Services\Crm\Salesforce\Fields\ValueNormalizer;
use Jiminny\Services\Crm\Salesforce\ServiceTraits\FollowupActivityTrait;
use Jiminny\Services\Crm\Salesforce\ServiceTraits\LogActivityTrait;
use Jiminny\Services\Crm\Salesforce\ServiceTraits\RecordManipulationsTrait;
use Jiminny\Services\Crm\Salesforce\ServiceTraits\SyncFieldsTrait;
use Jiminny\Utils\CurrencyFormatter;
use Jiminny\Utils\StringUtil;
use Ramsey\Uuid\Uuid;
use Sentry\Laravel\Facade as Sentry;
class Service extends BaseService implements
SalesforceInterface,
SalesforceBatchSyncInterface,
SyncCrmEntitiesInterface,
SyncCrmProfileRecordTypesInterface,
ImportsBusinessProcessesInterface,
RemoteEntityManipulationInterface,
FetchRelatedActivityInterface,
SendSummaryToCrmInterface,
MatchDomainByEmailInterface,
SearchTaskInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
SupportsObjectTypeParseInterface,
RemoteNoteEntityManipulationInterface,
VerifyTaskExistsInterface
{
use ResolveCompanyNameByEmailTrait;
use SyncFieldsTrait;
use DeleteObjectsTrait;
use RecordManipulationsTrait;
use ServiceTraits\BatchSyncTrait;
use FollowupActivityTrait;
use LogActivityTrait;
/**
* Note Body Limit for the Old Note-Taking Tool
*
* @var int
*/
private const int CLASSIC_NOTE_MAX_LENGTH = 32000;
/**
* Note Content Limit for the New Notes
*
* @var int
*/
private const int ENHANCED_NOTE_MAX_LENGTH = 50000000;
private const string INSTALLED_PACKAGE_ID = '033Tw0000007bKbIAI';
private const int CACHE_TTL = 600;
private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day - 86400
/**
* @var Client
*/
protected $client;
protected PayloadBuilder $payloadBuilder;
protected QueryHandler $queryHandler;
private OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
public function __construct(
Client $client,
PayloadBuilder $payloadBuilder,
protected Dispatcher $eventDispatcher,
private readonly CountriesMap $countriesMap,
private readonly ProspectPhotoPathService $prospectPhotoPathService,
) {
parent::__construct();
$this->client = $client;
$this->payloadBuilder = $payloadBuilder;
$this->queryHandler = app(QueryHandler::class, [
'client' => $this->client,
'logger' => $this->logger,
]);
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
}
public function getDisplayName(): string
{
return 'Salesforce';
}
public function getJobDelay(): int
{
return 1;
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
return $user->getSocialAccount(SocialAccount::PROVIDER_SALESFORCE);
}
public function verifyTaskExists(Activity $activity): bool
{
$crmProviderId = $activity->getCrmProviderId();
$cacheKey = "crm_task_exists:{$this->config->getId()}:$crmProviderId";
return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($activity, $crmProviderId) {
$playbook = $this->getPlaybookFromActivity($activity);
if ($playbook === null) {
$this->logger->warning('[Salesforce] Cannot verify task - no playbook found', [
'activity' => $activity->getId(),
'crm_provider_id' => $crmProviderId,
]);
return false;
}
$objectType = $playbook->getActivityType() === Playbook::ACTIVITY_TYPE_EVENT ? 'Event' : 'Task';
try {
$record = $this->getRecord($objectType, $crmProviderId, ['Id', 'IsDeleted']);
return ! empty($record) && ($record['IsDeleted'] ?? false) === false;
} catch (HttpNotFoundException|HttpBadRequestException) {
$this->logger->info('[Salesforce] Activity record not found during verification', [
'activity' => $activity->getId(),
'object_type' => $objectType,
'crm_provider_id' => $crmProviderId,
'config_id' => $this->config->getId(),
]);
return false;
}
});
}
public function query(string $queryToRun, array $parameters = []): QueryIterator
{
// Due to poorly designed external calls, this method cannot be entirely removed
return $this->queryHandler->query($queryToRun, $parameters);
}
/*=========== Organization Information ===============*/
/**
* Get a list of all the API Versions for the instance.
*
* @throws CrmException
*
* @return mixed
*
*/
public function getApiVersions()
{
$url = $this->config->crm_base_url . '/services/data';
$response = $this->client->get($url);
return json_decode($response->getBody(), true);
}
/**
* Gets the valid recordTypes for a given Salesforce Object via the describe API.
*/
private function getRecordTypes(string $crmObject): array
{
$url = $this->client->getObjectsUrl() . $crmObject . '/describe';
$response = $this->client->get($url);
$jsonResponse = json_decode($response->getBody(), true);
$fields = [];
foreach ($jsonResponse['recordTypeInfos'] as $row) {
$fields[] = ['recordTypeId' => $row['recordTypeId'], 'default' => $row['defaultRecordTypeMapping']];
}
return $fields;
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return ValueNormalizer::normalize($fieldType, $fieldValue, $internal);
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
$defaultFields = ($activityType === Playbook::ACTIVITY_TYPE_TASK)
? FieldDefinitions::defaultTaskFields()
: FieldDefinitions::defaultEventFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
// Setup the activity field as the default Type.
/** @var Field $activityField */
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'Type',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK, Playbook::ACTIVITY_TYPE_EVENT];
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldFilter = ($activityType === Playbook::ACTIVITY_TYPE_TASK)
? FieldDefinitions::taskFollowupFieldsFilter()
: FieldDefinitions::eventFollowupFieldsFilter();
foreach ($fieldFilter as $eachFilter) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $eachFilter);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
/**
* This one is now called only when ImportActivityTypes is triggered or SyncFieldMetadata executed manually
* Regular sync now uses SharedSyncFieldsTrait -> syncSingleObjectType
* Needs to be replaced later on
*/
public function syncField(Field $field): void
{
try {
if (FieldHelper::isCustomField($field)) {
$query = '
SELECT
Id, Metadata, TableEnumOrId
FROM
CustomField
WHERE
DeveloperName = :fieldName
AND
TableEnumOrId = :fieldType
AND
NamespacePrefix = :namespacePrefix';
// We need to constrain the field lookup to the object, in case it's used in multiple places.
$objectType = \in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true)
? 'activity'
: $field->object_type;
$sfFields = $this->queryHandler->metadata($query, [
'fieldName' => substr($field->crm_provider_id, 0, -\strlen('__c')),
'fieldType' => ucfirst($objectType),
// This is used to ensure we only consider the field within the org, not installed packages.
'namespacePrefix' => 'null',
]);
// There is always 1 result at this point.
$sfField = $sfFields->current();
// Sync field metadata.
$metadata = $sfField['Metadata'];
$field->description = mb_strimwidth($metadata['description'] ?? '', 0, 191);
$field->label = mb_strimwidth($metadata['label'] ?? '', 0, Field::LABEL_MAX_LENGTH);
$field->type = $this->convertFieldType($metadata['type'], $field->getEntityName());
$field->is_mandatory = ($metadata['required'] === true);
$field->length = $metadata['length'];
$field->default_value = mb_strimwidth(trim($metadata['defaultValue'] ?? '', '"'), 0, 191);
$field->save();
} else {
$query = '
SELECT
Id, DataType, DeveloperName, Label, Length, Description
FROM
FieldDefinition
WHERE
DurableId = :entityName';
$entityName = $field->getEntityName();
$sfFields = $this->queryHandler->metadata($query, [
'entityName' => $entityName,
]);
// There is always 1 result at this point.
$sfField = $sfFields->current();
$convertedType = $this->convertFieldType($sfField['DataType'], $entityName);
$label = mb_strimwidth($sfField['Label'], 0, Field::LABEL_MAX_LENGTH);
if ($field->isBusinessType()) {
$label = 'Opportunity Type';
}
$field->description = mb_strimwidth($sfField['Description'], 0, Field::DESCRIPTION_MAX_LENGTH);
$field->label = $label;
$field->type = $convertedType;
$field->length = $sfField['Length'];
$field->save();
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
}
private function convertFieldType(string $from, ?string $entityName = null): string
{
$converter = new FieldTypeConverter();
return $converter->convert($from, $entityName);
}
/**
* @inheritdoc
*/
public function importPicklistValues(Field $field): array
{
$values = [];
$fieldValues = [];
try {
if (FieldHelper::isCustomField($field)) {
$query = '
SELECT
Id, Metadata, TableEnumOrId
FROM
CustomField
WHERE
DeveloperName = :fieldName
AND
TableEnumOrId = :fieldType
AND
NamespacePrefix = :namespacePrefix';
// We need to constrain the field lookup to the object, in case it's used in multiple places.
$objectType = \in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true) ?
'activity' : $field->object_type;
$sfFields = $this->queryHandler->metadata($query, [
'fieldName' => substr($field->crm_provider_id, 0, -\strlen('__c')),
'fieldType' => ucfirst($objectType),
// This is used to ensure we only consider the field within the org, not installed packages.
'namespacePrefix' => 'null',
]);
// There is always 1 result at this point.
$sfField = $sfFields->current();
$valueSet = $sfField['Metadata']['valueSet'];
if ($valueSet['valueSetName'] === null) {
// Local picklist values can be obtained easily.
$picklistValues = $valueSet['valueSetDefinition']['value'];
} else {
// But for some fields, we just get the Global Value Picklist pointer so need to do more work.
$picklistValues = $this->importGlobalValuePicklistValues($valueSet['valueSetName']);
}
// Import all active values.
foreach ($picklistValues as $i => $sfFieldValue) {
// Setup default value.
if ($sfFieldValue['default']) {
$field->update(['default_value' => $sfFieldValue['valueName']]);
}
// This comes through as null if active (lol).
if ($sfFieldValue['isActive'] !== false) {
$values[] = [
'value' => $sfFieldValue['valueName'],
'label' => $sfFieldValue['valueName'],
'sequence' => $i,
'is_default' => $sfFieldValue['default'],
];
}
}
} else {
$objectFields = $this->getObjectFields($field->object_type);
$fieldId = $field->crm_provider_id;
// Only work with our field of interest.
$objectField = array_filter($objectFields, function ($item) use ($fieldId) {
return $item['name'] === $fieldId;
});
$objectField = array_shift($objectField);
if (empty($objectField['picklistValues']) === false) {
foreach ($objectField['picklistValues'] as $i => $sfFieldValue) {
// Skip inactive values.
if ($sfFieldValue['active'] === false) {
continue;
}
// Setup default value.
if ($sfFieldValue['defaultValue']) {
$field->update(['default_value' => $sfFieldValue['value']]);
}
$values[] = [
'value' => $sfFieldValue['value'],
'label' => $sfFieldValue['label'],
'sequence' => $i,
'is_default' => $sfFieldValue['defaultValue'],
];
}
}
}
$fieldsToPurge = $field->values()->get()->pluck('value')->toArray();
foreach ($values as $value) {
$value['value'] = substr($value['value'] ?? '', 0, 255);
$fieldValues[] = $field->values()->updateOrCreate([
'value' => $value['value'],
], $value);
// Remove this value from the ones we are going to purge.
if (($key = array_search($value['value'], $fieldsToPurge, true)) !== false) {
unset($fieldsToPurge[$key]);
}
}
// Delete the old values that are no longer used.
// Get IDs of the values to be deleted
$valuesToDelete = $field->values()->whereIn('value', $fieldsToPurge);
$valuesToDeleteIds = $valuesToDelete->pluck('id');
if (! $valuesToDeleteIds->isEmpty()) {
$recordTypeFieldValuesRepository = app(RecordTypeFieldValuesRepository::class);
$recordTypeFieldValuesRepository->deleteForCrmFieldValueIds($valuesToDeleteIds->toArray());
// Now safely delete from crm_field_values
$valuesToDelete->delete();
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
return $fieldValues;
}
/**
* Gets values from Global Value Picklists.
*/
private function importGlobalValuePicklistValues(string $picklistName): array
{
$query = '
SELECT
Metadata
FROM
GlobalValueSet
WHERE
DeveloperName = :picklistName
LIMIT 1';
try {
$sfValues = $this->queryHandler->metadata($query, [
'picklistName' => $picklistName,
]);
// There is always 1 result at this point.
$sfValue = $sfValues->current();
return $sfValue['Metadata']['customValue'];
} catch (NoResultsException $noResultsException) {
// Nothing returned.
return [];
}
}
/**
* @inheritdoc
*/
public function syncProfileRecordTypes(): void
{
$objectTypes = [
'lead',
'account',
'contact',
'opportunity',
'task',
'event',
];
foreach ($objectTypes as $objectType) {
try {
$crmRecordTypes = $this->getRecordTypes(ucfirst($objectType));
foreach ($crmRecordTypes as $crmRecordType) {
// If the record type is default and not the Master type, set this.
if ($crmRecordType['default'] && $crmRecordType['recordTypeId'] !== '012000000000000AAA') {
$recordType = $this->config->recordTypes()
->where('crm_provider_id', $crmRecordType['recordTypeId'])
->first();
if ($recordType) {
$this->profile->{$objectType . '_record_type_id'} = $recordType->id;
}
}
}
} catch (HttpNotFoundException $exception) {
Log::error('No access to ' . $objectType . ' object, skipping...');
// XXX: should we log this fact somewhere?
continue;
}
}
if ($this->profile->isDirty()) {
$this->profile->save();
}
}
/**
* Gets business processes.
*/
public function importBusinessProcesses(): void
{
$query = '
SELECT
Id, IsActive, Name, TableEnumOrId
FROM
BusinessProcess
WHERE
TableEnumOrId IN (\'Lead\',\'Opportunity\')';
try {
$sfProcesses = $this->queryHandler->query($query);
// Upsert all processes for the team.
foreach ($sfProcesses as $sfProcess) {
/** @var BusinessProcess $businessProcess */
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => $sfProcess['Id'],
], [
'team_id' => $this->team->id,
'name' => $sfProcess['Name'],
'type' => $sfProcess['TableEnumOrId'] === 'Lead' ? 'lead' : 'opportunity',
'is_selectable' => $sfProcess['IsActive'],
]);
$this->importBusinessProcessStages($businessProcess);
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
}
/**
* Gets business process stages.
*/
private function importBusinessProcessStages(BusinessProcess $businessProcess): void
{
$query = '
SELECT
Metadata
FROM
BusinessProcess
WHERE
Id = :processId';
try {
$stages = [];
$sfProcessStages = $this->queryHandler->metadata($query, [
'processId' => $businessProcess->crm_provider_id,
]);
// There is always 1 result at this point.
$sfProcessStage = $sfProcessStages->current();
// Upsert all processes for the team.
foreach ($sfProcessStage['Metadata']['values'] as $sfProcessStage) {
$sanitizedName = urldecode($sfProcessStage['valueName']); // Must decode: "%2C" becomes "," etc.
$stage = $businessProcess->crm->stages()
// This MUST match on label because this API doesn't use API Name.
->where('label', $sanitizedName)
->where('type', $businessProcess->type)
->where('is_selectable', 1)
->first();
if ($stage) {
$stages[] = $stage->id;
}
}
$businessProcess->stages()->sync($stages);
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
}
/**
* Gets record types.
*/
public function importRecordTypes(): void
{
$query = '
SELECT
Id, IsActive, Name, BusinessProcessId, SobjectType
FROM
RecordType';
try {
$sfRecordTypes = $this->queryHandler->query($query);
// Upsert all record types for the process.
foreach ($sfRecordTypes as $sfRecordType) {
$businessProcess = null;
if ($sfRecordType['BusinessProcessId']) {
$businessProcess = $this->config->businessProcesses()
->where('crm_provider_id', $sfRecordType['BusinessProcessId'])
->first();
}
/** @var RecordType $recordType */
$recordType = $this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => $sfRecordType['Id'],
], [
'team_id' => $this->team->id,
'type' => mb_strtolower($sfRecordType['SobjectType']),
'name' => $sfRecordType['Name'],
'is_selectable' => $sfRecordType['IsActive'],
'business_process_id' => $businessProcess->id ?? null,
]);
$this->importRecordTypeFieldValues($recordType);
}
} catch (NoResultsException $noResultsException) {
// Do nothing.
}
}
/**
* Import record type - field value mappings. This only works for standard fields.
*/
private function importRecordTypeFieldValues(RecordType $recordType): void
{
try {
$query = '
SELECT
Metadata
FROM
RecordType
WHERE
Id = :recordTypeId';
$sfFields = $this->queryHandler->metadata($query, [
'recordTypeId' => $recordType->crm_provider_id,
]);
// There is always 1 result at this point.
$sfField = $sfFields->current();
// Sync field metadata.
$picklists = $sfField['Metadata']['picklistValues'];
foreach ($picklists as $picklist) {
$field = $this->config->fields()->where([
'type' => Field::TYPE_PICKLIST,
'object_type' => $recordType->type,
'crm_provider_id' => $picklist['picklist'],
])->first();
if ($field) {
$fieldValues = [];
foreach ($picklist['values'] as $value) {
// Must decode: "%2C" becomes "," etc.
$fieldValue = $field->values()
->where('value', urldecode($value['valueName']))
->first();
if ($fieldValue) {
$fieldValues[] = $fieldValue->id;
}
}
$recordType->fieldValues()->sync($fieldValues);
}
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
}
/**
* @inheritdoc
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$params = [];
$missingStage = null;
if ($types === null) {
$types = [Stage::TYPE_LEAD, Stage::TYPE_OPPORTUNITY];
}
foreach ($types as $type) {
if ($type === Stage::TYPE_LEAD) {
$query = '
SELECT
Id, ApiName, MasterLabel, SortOrder
FROM
LeadStatus';
} else {
$query = '
SELECT
Id, ApiName, MasterLabel, IsActive, SortOrder, DefaultProbability
FROM
OpportunityStage';
}
if ($missingStageName) {
$escapedStageName = ValueNormalizer::replaceQueryWithStringLiterals($missingStageName);
$query .= ' WHERE ApiName = :stageName';
$params = [
'stageName' => $escapedStageName,
];
}
try {
$sfStages = $this->queryHandler->query($query, $params);
} catch (NoResultsException $exception) {
$sfStages = [];
}
$missingStage = null;
// Upsert all stages for the team.
foreach ($sfStages as $sfStage) {
$selectable = true;
if (array_key_exists('IsActive', $sfStage)) {
$selectable = $sfStage['IsActive'];
}
$this->restoreAnyTrashedEntity($this->config->stages(), $sfStage['Id']);
$stage = $this->config->stages()->updateOrCreate([
'crm_provider_id' => $sfStage['Id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($sfStage['ApiName'], 0, 50),
'label' => mb_strimwidth($sfStage['MasterLabel'], 0, 191),
'type' => $type,
'sequence' => $sfStage['SortOrder'] ?? 0,
'is_selectable' => $selectable,
'probability' => $sfStage['DefaultProbability'] ?? null,
]);
if ($missingStageName && $missingStageName === $sfStage['ApiName']) {
$missingStage = $stage;
}
}
if ($missingStageName && $missingStage === null) {
// If they requested a stage that still doesn't exist, it must be inactive so lazy create it.
$missingStage = $this->config->stages()->create([
'crm_provider_id' => Uuid::uuid4(),
'team_id' => $this->team->id,
'name' => mb_strimwidth($missingStageName, 0, 50),
'label' => mb_strimwidth($missingStageName, 0, 191),
'type' => $type,
'sequence' => 0,
'is_selectable' => 0,
]);
}
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int
{
$syncCount = 0;
$fields = $this->getAllFieldsAsArray('lead');
if (\in_array('Id', $fields, true) === false) {
return $syncCount;
}
$query = '
SELECT ' . rtrim(implode(',', $fields), ',') . '
FROM Lead
WHERE LastModifiedDate > :since
ORDER BY LastModifiedDate ASC';
try {
$sfLeads = $this->queryHandler->query($query, [
'since' => $since->format('Y-m-d\TH:i:s\Z'),
]);
foreach ($sfLeads as $sfLead) {
// Only sync if previously imported.
if ($this->hasLead($sfLead['Id'])) {
$this->importLead($sfLead);
$syncCount++;
}
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
$this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::LEAD);
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncLead(string $crmId): ?Lead
{
$fields = $this->getAllFieldsAsArray('lead');
$sfLead = $this->getRecord('Lead', $crmId, $fields);
return $this->importLead($sfLead);
}
private function importLead($crmData): ?Lead
{
/** @var ?Stage $stage */
$stage = null;
if (isset($crmData['Status'])) {
// Get the current stage.
$stage = $this->config
->stages()
->where('name', $crmData['Status'])
->where('type', Stage::TYPE_LEAD)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_LEAD], $crmData['Status']);
}
}
// If we have no way of importing this, just return null :(
if ($stage === null) {
return null;
}
$countryCode = $crmData['CountryCode'] ?? null;
// Salesforce allows custom "countries" to be created. Disregard these.
if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {
$countryCode = null;
}
// If we have no country code, try to parse it from the country name.
if ($countryCode === null && empty($crmData['Country']) !== false) {
$countryCode = $this->convertCountryNameToCode($crmData['Country']);
}
// Trim to our width and attempt to parse it.
$number = mb_strimwidth($crmData['Phone'] ?? '', 0, 25);
$parsedNumber = parsePhoneNumber($countryCode, $number);
$mobilePhone = null;
if (empty($crmData['MobilePhone']) === false) {
// Trim to our width and attempt to parse it.
$number = mb_strimwidth($crmData['MobilePhone'], 0, 25);
$mobilePhone = phone_e164($countryCode, $number);
}
$convertedDate = null;
$convertedAccount = null;
$convertedOpportunity = null;
$convertedContact = null;
if ($crmData['IsConverted'] == 'true') {
$convertedDate = $crmData['ConvertedDate'];
if (empty($crmData['ConvertedAccountId']) === false) {
$convertedAccount = $this->config
->accounts()
->where('crm_provider_id', $crmData['ConvertedAccountId'])
->first();
if ($convertedAccount === null) {
try {
$convertedAccount = $this->syncAccount($crmData['ConvertedAccountId']);
} catch (HttpNotFoundException $exception) {
// Probably the user has no permissions to access the converted data.
}
}
}
if (empty($crmData['ConvertedOpportunityId']) === false) {
$convertedOpportunity = $this->config
->opportunities()
->where('crm_provider_id', $crmData['ConvertedOpportunityId'])
->first();
if ($convertedOpportunity === null) {
try {
$convertedOpportunity = $this->syncOpportunity($crmData['ConvertedOpportunityId']);
} catch (HttpNotFoundException $exception) {
// Probably the user has no permissions to access the converted data.
}
}
}
if (empty($crmData['ConvertedContactId']) === false) {
$convertedContact = $this->team
->crm
->contacts()
->where('crm_provider_id', $crmData['ConvertedContactId'])
->first();
if ($convertedContact === null) {
try {
$convertedContact = $this->syncContact($crmData['ConvertedContactId']);
} catch (HttpNotFoundException $exception) {
// Probably the user has no permissions to access the converted data.
}
}
}
}
if (empty($crmData['Company'])) {
$company = 'Unknown';
} else {
$company = mb_strimwidth($crmData['Company'], 0, 191);
}
$domain = null;
if (empty($crmData['Website']) === false) {
$domain = mb_strimwidth($crmData['Website'], 0, 191);
$domain = StringUtil::resolveDomain($domain);
}
$createdDate = null;
if (empty($crmData['CreatedDate']) === false) {
$createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');
}
$profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);
$data = [
'team_id' => $this->team->id,
'user_id' => $profile?->user_id,
'owner_id' => $crmData['OwnerId'] ?? '',
'company' => $company,
'domain' => $domain,
'name' => $crmData['Name'] ? mb_strimwidth($crmData['Name'], 0, 191) : '',
'title' => $crmData['Title'] ? mb_strimwidth($crmData['Title'], 0, 128) : null,
'email' => $crmData['Email'] ? mb_strimwidth($crmData['Email'], 0, 80) : null,
'phone' => $parsedNumber['phone'],
'ext' => $parsedNumber['ext'] ?? null,
'mobile_phone' => $mobilePhone,
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: $crmData['Id'],
modelType: Lead::class,
fileName: $crmData['Id'],
avatarText: $crmData['Name']
),
'stage_id' => $stage->id,
'record_type_id' => null,
'converted_at' => $convertedDate,
'converted_account_id' => $convertedAccount->id ?? null,
'converted_opportunity_id' => $convertedOpportunity->id ?? null,
'converted_contact_id' => $convertedContact->id ?? null,
'country_code' => $countryCode,
'remotely_created_at' => $createdDate,
];
$this->restoreAnyTrashedEntity($this->config->leads(), $crmData['Id']);
/** @var Lead $lead */
$lead = $this->config->leads()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);
if ($lead->wasChanged('converted_at') && $lead->getConvertedAt() !== null) {
$this->eventDispatcher->dispatch(new LeadConverted($lead));
}
$this->handleObjectDeletion($lead, $crmData);
return $lead;
}
/**
* @inheritdoc
*/
public function syncAccounts(Carbon $since, ?Carbon $to = null): int
{
$syncCount = 0;
$fields = $this->getAllFieldsAsArray('account');
if (\in_array('Id', $fields, true) === false) {
return $syncCount;
}
$query = '
SELECT ' . rtrim(implode(',', $fields), ',') . '
FROM Account
WHERE LastModifiedDate > :since
ORDER BY LastModifiedDate ASC';
try {
$sfAccounts = $this->queryHandler->query($query, [
'since' => $since->format('Y-m-d\TH:i:s\Z'),
]);
foreach ($sfAccounts as $sfAccount) {
// Only sync if previously imported.
if ($this->hasAccount($sfAccount['Id'])) {
$this->importAccount($sfAccount);
$syncCount++;
}
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
$this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::ACCOUNT);
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncAccount(string $crmId): ?Account
{
$fields = $this->getAllFieldsAsArray('account');
if (! in_array('Id', $fields, true)) {
$this->logger->info('[Salesforce] Sync account cancelled. Fields are not available.', [
'crmId' => $crmId,
'userId' => $this->profile->getUserId(),
]);
return null;
}
$sfAccount = $this->getRecord('Account', $crmId, $fields);
return $this->importAccount($sfAccount);
}
private function importAccount($crmData): Account
{
$countryCode = $crmData['BillingCountryCode'] ?? $crmData['ShippingCountryCode'] ?? null;
// Salesforce allows custom "countries" to be created. Disregard these.
if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {
$countryCode = null;
}
// If we have no country code, try to parse it from the country names.
if ($countryCode === null && empty($crmData['BillingCountry']) === false) {
$countryCode = $this->convertCountryNameToCode($crmData['BillingCountry']);
}
if ($countryCode === null && empty($crmData['ShippingCountry']) === false) {
$countryCode = $this->convertCountryNameToCode($crmData['ShippingCountry']);
}
if (empty($crmData['Phone']) === false) {
// Trim to our width and attempt to parse it.
$number = mb_strimwidth($crmData['Phone'], 0, 25);
$parsedNumber = parsePhoneNumber($countryCode, $number);
} else {
$parsedNumber = [];
}
$industry = null;
if (empty($crmData['Industry']) === false) {
$industry = mb_strimwidth($crmData['Industry'], 0, 40);
}
$domain = null;
if (empty($crmData['Website']) === false) {
$domain = mb_strimwidth($crmData['Website'], 0, 191);
$domain = StringUtil::resolveDomain($domain);
}
$createdDate = null;
if (empty($crmData['CreatedDate']) === false) {
$createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');
}
$profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);
$data = [
'team_id' => $this->team->id,
'user_id' => $profile?->user_id,
'owner_id' => $crmData['OwnerId'],
'name' => mb_strimwidth($crmData['Name'], 0, 191),
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: $crmData['Id'],
modelType: Account::class,
fileName: $crmData['Id'],
avatarText: $crmData['Name']
),
'industry' => $industry,
'domain' => $domain,
'phone' => $parsedNumber['phone'] ?? null,
'ext' => $parsedNumber['ext'] ?? null,
'country_code' => $countryCode,
'remotely_created_at' => $createdDate,
];
$this->restoreAnyTrashedEntity($this->config->accounts(), $crmData['Id']);
/** @var Account $account */
$account = $this->config->accounts()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);
$this->handleObjectDeletion($account, $crmData);
return $account;
}
/**
* @inheritdoc
*/
public function syncOpportunities(array $parameters, ?string $strategy = null): int
{
$strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);
$syncCount = 0;
$logParams = $parameters;
$parameters['profile'] = $this->profile;
$logParams['user'] = $this->profile->getUserId();
if (count($strategies) > 1) {
$this->logger->warning('[' . $this->getDisplayName() . '] Multiple sync strategies used', [
'teamId' => $this->team->getUuid(),
'params' => $logParams,
'strategies_count' => count($strategies),
]);
}
foreach ($strategies as $syncStrategy) {
$name = $syncStrategy->getStrategyName();
try {
$sfOpportunities = $syncStrategy->fetchOpportunities($parameters);
$totalRecords = $sfOpportunities->count();
foreach ($sfOpportunities as $sfOpportunity) {
$this->importOpportunity($sfOpportunity);
$syncCount++;
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
$this->logger->warning('[' . $this->getDisplayName() . '] No opportunities found', [
'teamId' => $this->team->getUuid(),
'name' => $name,
'params' => $logParams,
'reason' => $noResultsException->getMessage(),
]);
} catch (CrmException $crmException) {
// Nothing to sync.
$this->logger->warning('[' . $this->getDisplayName() . '] Opportunity sync failed', [
'teamId' => $this->team->getUuid(),
'name' => $name,
'params' => $logParams,
'reason' => $crmException->getMessage(),
]);
}
}
$this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::OPPORTUNITY, ['params' => $logParams]);
// debug to see how if count of opportunities reaches 1000
if ($syncCount >= 1000) {
$this->logger->info(
'[' . $this->getDisplayName() . '] Sync Opportunities - count warning',
[
'team_id' => $this->team->getId(),
'params' => $logParams,
'count' => $syncCount,
'strategies_count' => count($strategies),
'total_records' => $totalRecords ?? null,
]
);
}
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncOpportunity(string $crmId): ?Opportunity
{
$strategy = $this->opportunitySyncStrategyResolver->resolve(
$this->config,
OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY
);
$parameters = [
'profile' => $this->profile,
'crm_id' => $crmId,
];
try {
$sfOpportunity = $strategy->fetchOpportunities($parameters);
} catch (HttpNotFoundException $e) {
$this->logger->info('[' . $this->getDisplayName() . '] Opportunity not found', [
'teamId' => $this->team->id_string,
'crmId' => $crmId,
]);
return null;
} catch (CrmException $crmException) {
$this->logger->info('[' . $this->getDisplayName() . '] Opportunity sync failed', [
'teamId' => $this->team->id_string,
'crmId' => $crmId,
'exception' => $crmException->getMessage(),
]);
return null;
}
if ($sfOpportunity instanceof ArrayIterator) {
return $this->importOpportunity($sfOpportunity->getItems());
}
return $this->importOpportunity($sfOpportunity);
}
/**
* @throws HttpNotFoundException
*/
private function importOpportunity($crmData): ?Opportunity
{
$profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);
$account = null;
if (empty($crmData['AccountId']) === false) {
/** @var ?Account $account */
$account = $this->config->accounts()
->where('crm_provider_id', (string) $crmData['AccountId'])
->first();
if ($account === null) {
$account = $this->syncAccount($crmData['AccountId']);
}
}
$userId = $profile?->getUserId() ?? $account?->getUserId();
if ($user...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
70642
|
2536
|
12
|
2026-05-22T10:26:39.761491+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779445599761_m1.jpg...
|
PhpStorm
|
Commit Changes
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Diff
Changelist:
Changelist:
Changes
Show Diff
Rol Diff
Changelist:
Changelist:
Changes
Show Diff
Rollback...
Refresh
Group By
Expand All
Collapse All
1 modified
JY
Commit Message
Commit Message History
Git
Author:
Author:
Amend commit
Sign-off commit
Create extra commit with file movements
Commit Checks
Update copyright
Reformat code
Rearrange code
Optimize imports
Cleanup
Choose profile
Check malicious dependencies
Run external formatter
Configure
Perform SonarQube for IDE analysis
Find unused view files
Run Git hooks
Advanced Commit Checks
Analyze code
Choose profile
Check TODO
Configure
After Commit
Upload files to:
Upload files to:
<None>
Always use selected server or group of servers...
|
[{"role":"AXStaticText","text& [{"role":"AXStaticText","text":"Diff","depth":1,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Changelist:","depth":1,"on_screen":true,"role_description":"text"},{"role":"AXPopUpButton","text":"Changelist:","depth":1,"on_screen":true,"role_description":"pop up button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Changes","depth":6,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Show Diff","depth":2,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Rollback...","depth":2,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Refresh","depth":2,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Group By","depth":2,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand All","depth":2,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse All","depth":2,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"1 modified","depth":1,"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"JY","depth":2,"on_screen":true,"value":"JY","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Commit Message","depth":1,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Commit Message History","depth":2,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Git","depth":2,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Author:","depth":2,"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"Author:","depth":3,"on_screen":true,"role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Amend commit","depth":2,"on_screen":true,"help_text":"Merge this commit with the previous one","role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Sign-off commit","depth":2,"on_screen":true,"help_text":"<html>Adds the following line at the end of the commit message:<br/>Signed-off by: Lukas Kovalik <kovaliklukas@gmail.com></html>","role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Create extra commit with file movements","depth":2,"on_screen":false,"role_description":"checkbox","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Commit Checks","depth":2,"on_screen":true,"role_description":"text"},{"role":"AXCheckBox","text":"Update copyright","depth":2,"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Reformat code","depth":2,"on_screen":true,"help_text":"","role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Rearrange code","depth":2,"on_screen":true,"help_text":"","role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Optimize imports","depth":2,"on_screen":true,"help_text":"","role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Cleanup","depth":2,"on_screen":true,"help_text":"","role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"Choose profile","depth":2,"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Check malicious dependencies","depth":2,"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Run external formatter","depth":2,"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"Configure","depth":2,"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Perform SonarQube for IDE analysis","depth":2,"on_screen":true,"help_text":"","role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Find unused view files","depth":2,"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Run Git hooks","depth":2,"on_screen":false,"help_text":"If unchecked, Git hooks will be skipped with the '--no-verify' option for the upcoming commit","role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Advanced Commit Checks","depth":2,"on_screen":true,"role_description":"text"},{"role":"AXCheckBox","text":"Analyze code","depth":2,"on_screen":true,"help_text":"","role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"Choose profile","depth":2,"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Check TODO","depth":2,"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"Configure","depth":2,"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"After Commit","depth":2,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Upload files to:","depth":2,"on_screen":true,"role_description":"text"},{"role":"AXPopUpButton","text":"Upload files to:","depth":2,"on_screen":true,"role_description":"pop up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"<None>","depth":7,"on_screen":false,"role_description":"text"},{"role":"AXCheckBox","text":"Always use selected server or group of servers","depth":2,"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
6269830850201940101
|
7540446664130301761
|
typing_pause
|
hybrid
|
NULL
|
Diff
Changelist:
Changelist:
Changes
Show Diff
Rol Diff
Changelist:
Changelist:
Changes
Show Diff
Rollback...
Refresh
Group By
Expand All
Collapse All
1 modified
JY
Commit Message
Commit Message History
Git
Author:
Author:
Amend commit
Sign-off commit
Create extra commit with file movements
Commit Checks
Update copyright
Reformat code
Rearrange code
Optimize imports
Cleanup
Choose profile
Check malicious dependencies
Run external formatter
Configure
Perform SonarQube for IDE analysis
Find unused view files
Run Git hooks
Advanced Commit Checks
Analyze code
Choose profile
Check TODO
Configure
After Commit
Upload files to:
Upload files to:
<None>
Always use selected server or group of servers
SlackFile EditViewGoHistoryWindowHelpAPP (DOCKERDEV (-zsh)О $82APP (-zsh)tests/Unit/Services/Mail/0ffice/EmailApiClientTest.phptests/Unit/Services/Mail/TextRelayServiceTest.phptests/Unit/Services/RecallAI/RecallAIServiceTest.php79 files changed, 4254 insertions(+), 2020 deletions(-)create mode 100644•github/claude-reviewer/no-ticket-warning.txtcreate mode.github/claude-reviewer/scripts/fetch-jira-context.mjsdelete mode100644 app/Component/ES/Processor/BatchStatusInterface.phpcreate mode100644 app/Component/ES/Processor/RedisEntityExtractor.phpdelete mode100644 app/Component/ES/Processor/Traits/WorkerIdTrait.php100644 app/Component/ES/QueuePriorityEnum.phpcreate mode100644 front-end/src/components/DealInsights/Modals/__tests__/AskAnythingModacreatemode 100644 front-end/src/components/shared/AskAnything/__tests__/__snapshots__/Asdeletemode100644 front-end/src/components/shared/AskAnything/__tests__/__snapshots_/Ascreatemode100644 tests/Unit/Component/ES/Processor/RedisEntityExtractorTest.phpdelete mode100644 tests/Unit/Component/ES/Processor/Traits/WorkerIdTraitTest.phpcreate mode100644 tests/Unit/Component/ES/QueuePriorityEnumTest.phpcreate mode100644 tests/Unit/Notifications/Activities/SmsReceivedTest.phpcreate mode100644 tests/Unit/Services/Mail/TextRelayServiceTest.phplukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ co -b JY-20963-fix-imporSwitched to a new branch 'JY-20963-fix-import-on-deleted-entity'lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/Jiminny/app (JY-20963-fix-import-on-deleted-ent|docker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix--config=.php-cs-fixer.dist.phWhat's next:Try Docker Debug forseamless, persistent debugging tools in any container or image →Learn moreat [URL_WITH_CREDENTIALS] ~/jiminny/app (JY-20963-fix-import-on-deleted-ent)docker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.phPHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminski15 umconafi amp cont-fbteo.di5t. phPHP runtime: 8.3.30Running analysis on 7 cores with 10 files per process.Parallel runner is an experimental feature and may be unstable, use it at your own risk.Loaded config default from".php-cs-fixer.dist.php".5691/5691 [8100%Fixed 0 of 5691 files in 96.077 seconds, 67.00 MB memory usedWhat's next:Try Docker Debug for seamless, persistent debugging tools in anycontainer or imageLearn moreat [URL_WITH_CREDENTIALS] review requested by @VasilVasilev#12120 Jy 20842 add deleteevents for es by @Vasil Vasilev9 commits • 14 files changedJIRA: JY-20842Changes:• Introduce events and eventlisteners that allow sending"document deleted" events for ESconsumption - both Hard and Softdeleted documents will beremoved.• Loa...Show morejiminny/appAdded by Toast for GitHubMessage Toast+Aa..•...
|
70640
|
NULL
|
NULL
|
NULL
|
|
70641
|
2537
|
15
|
2026-05-22T10:26:39.660917+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779445599660_m2.jpg...
|
PhpStorm
|
Commit Changes
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Diff
Changelist:
Changelist:
Changes
Show Diff
Rol Diff
Changelist:
Changelist:
Changes
Show Diff
Rollback...
Refresh
Group By
Expand All
Collapse All
1 modified
J
Commit Message
Commit Message History
Git
Author:
Author:
Amend commit
Sign-off commit
Create extra commit with file movements
Commit Checks
Update copyright
Reformat code
Rearrange code
Optimize imports
Cleanup
Choose profile
Check malicious dependencies
Run external formatter
Configure
Perform SonarQube for IDE analysis
Find unused view files
Run Git hooks
Advanced Commit Checks
Analyze code
Choose profile
Check TODO
Configure
After Commit
Upload files to:
Upload files to:
<None>
Always use selected server or group of servers
Previous Difference
Next Difference
Jump to Source
Compare Previous File
Compare Next File
Go to Changed File…
Side-by-side viewer
Do not ignore
Highlight words
Collapse Unchanged Fragments
Synchronize Scrolling
Disable Editing
Settings
Help
4 differences
1aee7aad
<?php
namespace Jiminny\Services\Crm\Salesforce;
use Carbon\Carbon;
use Exception;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Events\Dispatcher;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Jiminny\Component\Country\CountriesMap;
use Jiminny\Contracts\Acl\PermissionEnum;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Contracts\Services\Crm\FetchRelatedActivityInterface;
use Jiminny\Contracts\Services\Crm\ImportsBusinessProcessesInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\SalesforceBatchSyncInterface;
use Jiminny\Contracts\Services\Crm\Provider\SalesforceInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\RemoteNoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SearchTaskInterface;
use Jiminny\Contracts\Services\Crm\SendSummaryToCrmInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SupportsObjectTypeParseInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmProfileRecordTypesInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Enums\CrmObject;
use Jiminny\Events\Activities\Crm\LeadConverted;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpBadRequestException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Exceptions\NoResultsException;
use Jiminny\Exceptions\ServiceUnavailableException;
use Jiminny\Jobs\Crm\NoteObject;
use Jiminny\Jobs\Crm\MatchActivitiesToNewOpportunity;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Calendar\CalendarEvent;
use Jiminny\Models\Contact;
use Jiminny\Models\Contracts\ActivityContract;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Models\Crm\ContactRole;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Crm\RecordType;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Playbook;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\TeamSettings;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\ContactRoleRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\Crm\RecordTypeFieldValuesRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Helpers\ArrayIterator;
use Jiminny\Services\Crm\MatchDomainByEmailInterface;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Services\Crm\ResolveCompanyNameByEmailTrait;
use Jiminny\Services\Crm\Salesforce\Fields\FieldHelper;
use Jiminny\Services\Crm\Salesforce\Fields\FieldTypeConverter;
use Jiminny\Services\Crm\Salesforce\Fields\ValueNormalizer;
use Jiminny\Services\Crm\Salesforce\ServiceTraits\FollowupActivityTrait;
use Jiminny\Services\Crm\Salesforce\ServiceTraits\LogActivityTrait;
use Jiminny\Services\Crm\Salesforce\ServiceTraits\RecordManipulationsTrait;
use Jiminny\Services\Crm\Salesforce\ServiceTraits\SyncFieldsTrait;
use Jiminny\Utils\CurrencyFormatter;
use Jiminny\Utils\StringUtil;
use Ramsey\Uuid\Uuid;
use Sentry\Laravel\Facade as Sentry;
class Service extends BaseService implements
SalesforceInterface,
SalesforceBatchSyncInterface,
SyncCrmEntitiesInterface,
SyncCrmProfileRecordTypesInterface,
ImportsBusinessProcessesInterface,
RemoteEntityManipulationInterface,
FetchRelatedActivityInterface,
SendSummaryToCrmInterface,
MatchDomainByEmailInterface,
SearchTaskInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
SupportsObjectTypeParseInterface,
RemoteNoteEntityManipulationInterface,
VerifyTaskExistsInterface
{
use ResolveCompanyNameByEmailTrait;
use SyncFieldsTrait;
use DeleteObjectsTrait;
use RecordManipulationsTrait;
use ServiceTraits\BatchSyncTrait;
use FollowupActivityTrait;
use LogActivityTrait;
/**
* Note Body Limit for the Old Note-Taking Tool
*
* @var int
*/
private const int CLASSIC_NOTE_MAX_LENGTH = 32000;
/**
* Note Content Limit for the New Notes
*
* @var int
*/
private const int ENHANCED_NOTE_MAX_LENGTH = 50000000;
private const string INSTALLED_PACKAGE_ID = '033Tw0000007bKbIAI';
private const int CACHE_TTL = 600;
private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day - 86400
/**
* @var Client
*/
protected $client;
protected PayloadBuilder $payloadBuilder;
protected QueryHandler $queryHandler;
private OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
public function __construct(
Client $client,
PayloadBuilder $payloadBuilder,
protected Dispatcher $eventDispatcher,
private readonly CountriesMap $countriesMap,
private readonly ProspectPhotoPathService $prospectPhotoPathService,
) {
parent::__construct();
$this->client = $client;
$this->payloadBuilder = $payloadBuilder;
$this->queryHandler = app(QueryHandler::class, [
'client' => $this->client,
'logger' => $this->logger,
]);
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
}
public function getDisplayName(): string
{
return 'Salesforce';
}
public function getJobDelay(): int
{
return 1;
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
return $user->getSocialAccount(SocialAccount::PROVIDER_SALESFORCE);
}
public function verifyTaskExists(Activity $activity): bool
{
$crmProviderId = $activity->getCrmProviderId();
$cacheKey = "crm_task_exists:{$this->config->getId()}:$crmProviderId";
return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($activity, $crmProviderId) {
$playbook = $this->getPlaybookFromActivity($activity);
if ($playbook === null) {
$this->logger->warning('[Salesforce] Cannot verify task - no playbook found', [
'activity' => $activity->getId(),
'crm_provider_id' => $crmProviderId,
]);
return false;
}
$objectType = $playbook->getActivityType() === Playbook::ACTIVITY_TYPE_EVENT ? 'Event' : 'Task';
try {
$record = $this->getRecord($objectType, $crmProviderId, ['Id', 'IsDeleted']);
return ! empty($record) && ($record['IsDeleted'] ?? false) === false;
} catch (HttpNotFoundException|HttpBadRequestException) {
$this->logger->info('[Salesforce] Activity record not found during verification', [
'activity' => $activity->getId(),
'object_type' => $objectType,
'crm_provider_id' => $crmProviderId,
'config_id' => $this->config->getId(),
]);
return false;
}
});
}
public function query(string $queryToRun, array $parameters = []): QueryIterator
{
// Due to poorly designed external calls, this method cannot be entirely removed
return $this->queryHandler->query($queryToRun, $parameters);
}
/*=========== Organization Information ===============*/
/**
* Get a list of all the API Versions for the instance.
*
* @throws CrmException
*
* @return mixed
*
*/
public function getApiVersions()
{
$url = $this->config->crm_base_url . '/services/data';
$response = $this->client->get($url);
return json_decode($response->getBody(), true);
}
/**
* Gets the valid recordTypes for a given Salesforce Object via the describe API.
*/
private function getRecordTypes(string $crmObject): array
{
$url = $this->client->getObjectsUrl() . $crmObject . '/describe';
$response = $this->client->get($url);
$jsonResponse = json_decode($response->getBody(), true);
$fields = [];
foreach ($jsonResponse['recordTypeInfos'] as $row) {
$fields[] = ['recordTypeId' => $row['recordTypeId'], 'default' => $row['defaultRecordTypeMapping']];
}
return $fields;
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return ValueNormalizer::normalize($fieldType, $fieldValue, $internal);
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
$defaultFields = ($activityType === Playbook::ACTIVITY_TYPE_TASK)
? FieldDefinitions::defaultTaskFields()
: FieldDefinitions::defaultEventFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
// Setup the activity field as the default Type.
/** @var Field $activityField */
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'Type',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK, Playbook::ACTIVITY_TYPE_EVENT];
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldFilter = ($activityType === Playbook::ACTIVITY_TYPE_TASK)
? FieldDefinitions::taskFollowupFieldsFilter()
: FieldDefinitions::eventFollowupFieldsFilter();
foreach ($fieldFilter as $eachFilter) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $eachFilter);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
/**
* This one is now called only when ImportActivityTypes is triggered or SyncFieldMetadata executed manually
* Regular sync now uses SharedSyncFieldsTrait -> syncSingleObjectType
* Needs to be replaced later on
*/
public function syncField(Field $field): void
{
try {
if (FieldHelper::isCustomField($field)) {
$query = '
SELECT
Id, Metadata, TableEnumOrId
FROM
CustomField
WHERE
DeveloperName = :fieldName
AND
TableEnumOrId = :fieldType
AND
NamespacePrefix = :namespacePrefix';
// We need to constrain the field lookup to the object, in case it's used in multiple places.
$objectType = \in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true)
? 'activity'
: $field->object_type;
$sfFields = $this->queryHandler->metadata($query, [
'fieldName' => substr($field->crm_provider_id, 0, -\strlen('__c')),
'fieldType' => ucfirst($objectType),
// This is used to ensure we only consider the field within the org, not installed packages.
'namespacePrefix' => 'null',
]);
// There is always 1 result at this point.
$sfField = $sfFields->current();
// Sync field metadata.
$metadata = $sfField['Metadata'];
$field->description = mb_strimwidth($metadata['description'] ?? '', 0, 191);
$field->label = mb_strimwidth($metadata['label'] ?? '', 0, Field::LABEL_MAX_LENGTH);
$field->type = $this->convertFieldType($metadata['type'], $field->getEntityName());
$field->is_mandatory = ($metadata['required'] === true);
$field->length = $metadata['length'];
$field->default_value = mb_strimwidth(trim($metadata['defaultValue'] ?? '', '"'), 0, 191);
$field->save();
} else {
$query = '
SELECT
Id, DataType, DeveloperName, Label, Length, Description
FROM
FieldDefinition
WHERE
DurableId = :entityName';
$entityName = $field->getEntityName();
$sfFields = $this->queryHandler->metadata($query, [
'entityName' => $entityName,
]);
// There is always 1 result at this point.
$sfField = $sfFields->current();
$convertedType = $this->convertFieldType($sfField['DataType'], $entityName);
$label = mb_strimwidth($sfField['Label'], 0, Field::LABEL_MAX_LENGTH);
if ($field->isBusinessType()) {
$label = 'Opportunity Type';
}
$field->description = mb_strimwidth($sfField['Description'], 0, Field::DESCRIPTION_MAX_LENGTH);
$field->label = $label;
$field->type = $convertedType;
$field->length = $sfField['Length'];
$field->save();
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
}
private function convertFieldType(string $from, ?string $entityName = null): string
{
$converter = new FieldTypeConverter();
return $converter->convert($from, $entityName);
}
/**
* @inheritdoc
*/
public function importPicklistValues(Field $field): array
{
$values = [];
$fieldValues = [];
try {
if (FieldHelper::isCustomField($field)) {
$query = '
SELECT
Id, Metadata, TableEnumOrId
FROM
CustomField
WHERE
DeveloperName = :fieldName
AND
TableEnumOrId = :fieldType
AND
NamespacePrefix = :namespacePrefix';
// We need to constrain the field lookup to the object, in case it's used in multiple places.
$objectType = \in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true) ?
'activity' : $field->object_type;
$sfFields = $this->queryHandler->metadata($query, [
'fieldName' => substr($field->crm_provider_id, 0, -\strlen('__c')),
'fieldType' => ucfirst($objectType),
// This is used to ensure we only consider the field within the org, not installed packages.
'namespacePrefix' => 'null',
]);
// There is always 1 result at this point.
$sfField = $sfFields->current();
$valueSet = $sfField['Metadata']['valueSet'];
if ($valueSet['valueSetName'] === null) {
// Local picklist values can be obtained easily.
$picklistValues = $valueSet['valueSetDefinition']['value'];
} else {
// But for some fields, we just get the Global Value Picklist pointer so need to do more work.
$picklistValues = $this->importGlobalValuePicklistValues($valueSet['valueSetName']);
}
// Import all active values.
foreach ($picklistValues as $i => $sfFieldValue) {
// Setup default value.
if ($sfFieldValue['default']) {
$field->update(['default_value' => $sfFieldValue['valueName']]);
}
// This comes through as null if active (lol).
if ($sfFieldValue['isActive'] !== false) {
$values[] = [
'value' => $sfFieldValue['valueName'],
'label' => $sfFieldValue['valueName'],
'sequence' => $i,
'is_default' => $sfFieldValue['default'],
];
}
}
} else {
$objectFields = $this->getObjectFields($field->object_type);
$fieldId = $field->crm_provider_id;
// Only work with our field of interest.
$objectField = array_filter($objectFields, function ($item) use ($fieldId) {
return $item['name'] === $fieldId;
});
$objectField = array_shift($objectField);
if (empty($objectField['picklistValues']) === false) {
foreach ($objectField['picklistValues'] as $i => $sfFieldValue) {
// Skip inactive values.
if ($sfFieldValue['active'] === false) {
continue;
}
// Setup default value.
if ($sfFieldValue['defaultValue']) {
$field->update(['default_value' => $sfFieldValue['value']]);
}
$values[] = [
'value' => $sfFieldValue['value'],
'label' => $sfFieldValue['label'],
'sequence' => $i,
'is_default' => $sfFieldValue['defaultValue'],
];
}
}
}
$fieldsToPurge = $field->values()->get()->pluck('value')->toArray();
foreach ($values as $value) {
$value['value'] = substr($value['value'] ?? '', 0, 255);
$fieldValues[] = $field->values()->updateOrCreate([
'value' => $value['value'],
], $value);
// Remove this value from the ones we are going to purge.
if (($key = array_search($value['value'], $fieldsToPurge, true)) !== false) {
unset($fieldsToPurge[$key]);
}
}
// Delete the old values that are no longer used.
// Get IDs of the values to be deleted
$valuesToDelete = $field->values()->whereIn('value', $fieldsToPurge);
$valuesToDeleteIds = $valuesToDelete->pluck('id');
if (! $valuesToDeleteIds->isEmpty()) {
$recordTypeFieldValuesRepository = app(RecordTypeFieldValuesRepository::class);
$recordTypeFieldValuesRepository->deleteForCrmFieldValueIds($valuesToDeleteIds->toArray());
// Now safely delete from crm_field_values
$valuesToDelete->delete();
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
return $fieldValues;
}
/**
* Gets values from Global Value Picklists.
*/
private function importGlobalValuePicklistValues(string $picklistName): array
{
$query = '
SELECT
Metadata
FROM
GlobalValueSet
WHERE
DeveloperName = :picklistName
LIMIT 1';
try {
$sfValues = $this->queryHandler->metadata($query, [
'picklistName' => $picklistName,
]);
// There is always 1 result at this point.
$sfValue = $sfValues->current();
return $sfValue['Metadata']['customValue'];
} catch (NoResultsException $noResultsException) {
// Nothing returned.
return [];
}
}
/**
* @inheritdoc
*/
public function syncProfileRecordTypes(): void
{
$objectTypes = [
'lead',
'account',
'contact',
'opportunity',
'task',
'event',
];
foreach ($objectTypes as $objectType) {
try {
$crmRecordTypes = $this->getRecordTypes(ucfirst($objectType));
foreach ($crmRecordTypes as $crmRecordType) {
// If the record type is default and not the Master type, set this.
if ($crmRecordType['default'] && $crmRecordType['recordTypeId'] !== '012000000000000AAA') {
$recordType = $this->config->recordTypes()
->where('crm_provider_id', $crmRecordType['recordTypeId'])
->first();
if ($recordType) {
$this->profile->{$objectType . '_record_type_id'} = $recordType->id;
}
}
}
} catch (HttpNotFoundException $exception) {
Log::error('No access to ' . $objectType . ' object, skipping...');
// XXX: should we log this fact somewhere?
continue;
}
}
if ($this->profile->isDirty()) {
$this->profile->save();
}
}
/**
* Gets business processes.
*/
public function importBusinessProcesses(): void
{
$query = '
SELECT
Id, IsActive, Name, TableEnumOrId
FROM
BusinessProcess
WHERE
TableEnumOrId IN (\'Lead\',\'Opportunity\')';
try {
$sfProcesses = $this->queryHandler->query($query);
// Upsert all processes for the team.
foreach ($sfProcesses as $sfProcess) {
/** @var BusinessProcess $businessProcess */
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => $sfProcess['Id'],
], [
'team_id' => $this->team->id,
'name' => $sfProcess['Name'],
'type' => $sfProcess['TableEnumOrId'] === 'Lead' ? 'lead' : 'opportunity',
'is_selectable' => $sfProcess['IsActive'],
]);
$this->importBusinessProcessStages($businessProcess);
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
}
/**
* Gets business process stages.
*/
private function importBusinessProcessStages(BusinessProcess $businessProcess): void
{
$query = '
SELECT
Metadata
FROM
BusinessProcess
WHERE
Id = :processId';
try {
$stages = [];
$sfProcessStages = $this->queryHandler->metadata($query, [
'processId' => $businessProcess->crm_provider_id,
]);
// There is always 1 result at this point.
$sfProcessStage = $sfProcessStages->current();
// Upsert all processes for the team.
foreach ($sfProcessStage['Metadata']['values'] as $sfProcessStage) {
$sanitizedName = urldecode($sfProcessStage['valueName']); // Must decode: "%2C" becomes "," etc.
$stage = $businessProcess->crm->stages()
// This MUST match on label because this API doesn't use API Name.
->where('label', $sanitizedName)
->where('type', $businessProcess->type)
->where('is_selectable', 1)
->first();
if ($stage) {
$stages[] = $stage->id;
}
}
$businessProcess->stages()->sync($stages);
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
}
/**
* Gets record types.
*/
public function importRecordTypes(): void
{
$query = '
SELECT
Id, IsActive, Name, BusinessProcessId, SobjectType
FROM
RecordType';
try {
$sfRecordTypes = $this->queryHandler->query($query);
// Upsert all record types for the process.
foreach ($sfRecordTypes as $sfRecordType) {
$businessProcess = null;
if ($sfRecordType['BusinessProcessId']) {
$businessProcess = $this->config->businessProcesses()
->where('crm_provider_id', $sfRecordType['BusinessProcessId'])
->first();
}
/** @var RecordType $recordType */
$recordType = $this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => $sfRecordType['Id'],
], [
'team_id' => $this->team->id,
'type' => mb_strtolower($sfRecordType['SobjectType']),
'name' => $sfRecordType['Name'],
'is_selectable' => $sfRecordType['IsActive'],
'business_process_id' => $businessProcess->id ?? null,
]);
$this->importRecordTypeFieldValues($recordType);
}
} catch (NoResultsException $noResultsException) {
// Do nothing.
}
}
/**
* Import record type - field value mappings. This only works for standard fields.
*/
private function importRecordTypeFieldValues(RecordType $recordType): void
{
try {
$query = '
SELECT
Metadata
FROM
RecordType
WHERE
Id = :recordTypeId';
$sfFields = $this->queryHandler->metadata($query, [
'recordTypeId' => $recordType->crm_provider_id,
]);
// There is always 1 result at this point.
$sfField = $sfFields->current();
// Sync field metadata.
$picklists = $sfField['Metadata']['picklistValues'];
foreach ($picklists as $picklist) {
$field = $this->config->fields()->where([
'type' => Field::TYPE_PICKLIST,
'object_type' => $recordType->type,
'crm_provider_id' => $picklist['picklist'],
])->first();
if ($field) {
$fieldValues = [];
foreach ($picklist['values'] as $value) {
// Must decode: "%2C" becomes "," etc.
$fieldValue = $field->values()
->where('value', urldecode($value['valueName']))
->first();
if ($fieldValue) {
$fieldValues[] = $fieldValue->id;
}
}
$recordType->fieldValues()->sync($fieldValues);
}
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
}
/**
* @inheritdoc
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$params = [];
$missingStage = null;
if ($types === null) {
$types = [Stage::TYPE_LEAD, Stage::TYPE_OPPORTUNITY];
}
foreach ($types as $type) {
if ($type === Stage::TYPE_LEAD) {
$query = '
SELECT
Id, ApiName, MasterLabel, SortOrder
FROM
LeadStatus';
} else {
$query = '
SELECT
Id, ApiName, MasterLabel, IsActive, SortOrder, DefaultProbability
FROM
OpportunityStage';
}
if ($missingStageName) {
$escapedStageName = ValueNormalizer::replaceQueryWithStringLiterals($missingStageName);
$query .= ' WHERE ApiName = :stageName';
$params = [
'stageName' => $escapedStageName,
];
}
try {
$sfStages = $this->queryHandler->query($query, $params);
} catch (NoResultsException $exception) {
$sfStages = [];
}
$missingStage = null;
// Upsert all stages for the team.
foreach ($sfStages as $sfStage) {
$selectable = true;
if (array_key_exists('IsActive', $sfStage)) {
$selectable = $sfStage['IsActive'];
}
$this->restoreAnyTrashedEntity($this->config->stages(), $sfStage['Id']);
$stage = $this->config->stages()->updateOrCreate([
'crm_provider_id' => $sfStage['Id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($sfStage['ApiName'], 0, 50),
'label' => mb_strimwidth($sfStage['MasterLabel'], 0, 191),
'type' => $type,
'sequence' => $sfStage['SortOrder'] ?? 0,
'is_selectable' => $selectable,
'probability' => $sfStage['DefaultProbability'] ?? null,
]);
if ($missingStageName && $missingStageName === $sfStage['ApiName']) {
$missingStage = $stage;
}
}
if ($missingStageName && $missingStage === null) {
// If they requested a stage that still doesn't exist, it must be inactive so lazy create it.
$missingStage = $this->config->stages()->create([
'crm_provider_id' => Uuid::uuid4(),
'team_id' => $this->team->id,
'name' => mb_strimwidth($missingStageName, 0, 50),
'label' => mb_strimwidth($missingStageName, 0, 191),
'type' => $type,
'sequence' => 0,
'is_selectable' => 0,
]);
}
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int
{
$syncCount = 0;
$fields = $this->getAllFieldsAsArray('lead');
if (\in_array('Id', $fields, true) === false) {
return $syncCount;
}
$query = '
SELECT ' . rtrim(implode(',', $fields), ',') . '
FROM Lead
WHERE LastModifiedDate > :since
ORDER BY LastModifiedDate ASC';
try {
$sfLeads = $this->queryHandler->query($query, [
'since' => $since->format('Y-m-d\TH:i:s\Z'),
]);
foreach ($sfLeads as $sfLead) {
// Only sync if previously imported.
if ($this->hasLead($sfLead['Id'])) {
$this->importLead($sfLead);
$syncCount++;
}
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
$this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::LEAD);
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncLead(string $crmId): ?Lead
{
$fields = $this->getAllFieldsAsArray('lead');
$sfLead = $this->getRecord('Lead', $crmId, $fields);
return $this->importLead($sfLead);
}
private function importLead($crmData): ?Lead
{
/** @var ?Stage $stage */
$stage = null;
if (isset($crmData['Status'])) {
// Get the current stage.
$stage = $this->config
->stages()
->where('name', $crmData['Status'])
->where('type', Stage::TYPE_LEAD)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_LEAD], $crmData['Status']);
}
}
// If we have no way of importing this, just return null :(
if ($stage === null) {
return null;
}
$countryCode = $crmData['CountryCode'] ?? null;
// Salesforce allows custom "countries" to be created. Disregard these.
if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {
$countryCode = null;
}
// If we have no country code, try to parse it from the country name.
if ($countryCode === null && empty($crmData['Country']) !== false) {
$countryCode = $this->convertCountryNameToCode($crmData['Country']);
}
// Trim to our width and attempt to parse it.
$number = mb_strimwidth($crmData['Phone'] ?? '', 0, 25);
$parsedNumber = parsePhoneNumber($countryCode, $number);
$mobilePhone = null;
if (empty($crmData['MobilePhone']) === false) {
// Trim to our width and attempt to parse it.
$number = mb_strimwidth($crmData['MobilePhone'], 0, 25);
$mobilePhone = phone_e164($countryCode, $number);
}
$convertedDate = null;
$convertedAccount = null;
$convertedOpportunity = null;
$convertedContact = null;
if ($crmData['IsConverted'] == 'true') {
$convertedDate = $crmData['ConvertedDate'];
if (empty($crmData['ConvertedAccountId']) === false) {
$convertedAccount = $this->config
->accounts()
->where('crm_provider_id', $crmData['ConvertedAccountId'])
->first();
if ($convertedAccount === null) {
try {
$convertedAccount = $this->syncAccount($crmData['ConvertedAccountId']);
} catch (HttpNotFoundException $exception) {
// Probably the user has no permissions to access the converted data.
}
}
}
if (empty($crmData['ConvertedOpportunityId']) === false) {
$convertedOpportunity = $this->config
->opportunities()
->where('crm_provider_id', $crmData['ConvertedOpportunityId'])
->first();
if ($convertedOpportunity === null) {
try {
$convertedOpportunity = $this->syncOpportunity($crmData['ConvertedOpportunityId']);
} catch (HttpNotFoundException $exception) {
// Probably the user has no permissions to access the converted data.
}
}
}
if (empty($crmData['ConvertedContactId']) === false) {
$convertedContact = $this->team
->crm
->contacts()
->where('crm_provider_id', $crmData['ConvertedContactId'])
->first();
if ($convertedContact === null) {
try {
$convertedContact = $this->syncContact($crmData['ConvertedContactId']);
} catch (HttpNotFoundException $exception) {
// Probably the user has no permissions to access the converted data.
}
}
}
}
if (empty($crmData['Company'])) {
$company = 'Unknown';
} else {
$company = mb_strimwidth($crmData['Company'], 0, 191);
}
$domain = null;
if (empty($crmData['Website']) === false) {
$domain = mb_strimwidth($crmData['Website'], 0, 191);
$domain = StringUtil::resolveDomain($domain);
}
$createdDate = null;
if (empty($crmData['CreatedDate']) === false) {
$createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');
}
$profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);
$data = [
'team_id' => $this->team->id,
'user_id' => $profile?->user_id,
'owner_id' => $crmData['OwnerId'] ?? '',
'company' => $company,
'domain' => $domain,
'name' => $crmData['Name'] ? mb_strimwidth($crmData['Name'], 0, 191) : '',
'title' => $crmData['Title'] ? mb_strimwidth($crmData['Title'], 0, 128) : null,
'email' => $crmData['Email'] ? mb_strimwidth($crmData['Email'], 0, 80) : null,
'phone' => $parsedNumber['phone'],
'ext' => $parsedNumber['ext'] ?? null,
'mobile_phone' => $mobilePhone,
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: $crmData['Id'],
modelType: Lead::class,
fileName: $crmData['Id'],
avatarText: $crmData['Name']
),
'stage_id' => $stage->id,
'record_type_id' => null,
'converted_at' => $convertedDate,
'converted_account_id' => $convertedAccount->id ?? null,
'converted_opportunity_id' => $convertedOpportunity->id ?? null,
'converted_contact_id' => $convertedContact->id ?? null,
'country_code' => $countryCode,
'remotely_created_at' => $createdDate,
];
$this->restoreAnyTrashedEntity($this->config->leads(), $crmData['Id']);
/** @var Lead $lead */
$lead = $this->config->leads()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);
if ($lead->wasChanged('converted_at') && $lead->getConvertedAt() !== null) {
$this->eventDispatcher->dispatch(new LeadConverted($lead));
}
$this->handleObjectDeletion($lead, $crmData);
return $lead;
}
/**
* @inheritdoc
*/
public function syncAccounts(Carbon $since, ?Carbon $to = null): int
{
$syncCount = 0;
$fields = $this->getAllFieldsAsArray('account');
if (\in_array('Id', $fields, true) === false) {
return $syncCount;
}
$query = '
SELECT ' . rtrim(implode(',', $fields), ',') . '
FROM Account
WHERE LastModifiedDate > :since
ORDER BY LastModifiedDate ASC';
try {
$sfAccounts = $this->queryHandler->query($query, [
'since' => $since->format('Y-m-d\TH:i:s\Z'),
]);
foreach ($sfAccounts as $sfAccount) {
// Only sync if previously imported.
if ($this->hasAccount($sfAccount['Id'])) {
$this->importAccount($sfAccount);
$syncCount++;
}
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
$this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::ACCOUNT);
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncAccount(string $crmId): ?Account
{
$fields = $this->getAllFieldsAsArray('account');
if (! in_array('Id', $fields, true)) {
$this->logger->info('[Salesforce] Sync account cancelled. Fields are not available.', [
'crmId' => $crmId,
'userId' => $this->profile->getUserId(),
]);
return null;
}
$sfAccount = $this->getRecord('Account', $crmId, $fields);
return $this->importAccount($sfAccount);
}
private function importAccount($crmData): Account
{
$countryCode = $crmData['BillingCountryCode'] ?? $crmData['ShippingCountryCode'] ?? null;
// Salesforce allows custom "countries" to be created. Disregard these.
if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {
$countryCode = null;
}
// If we have no country code, try to parse it from the country names.
if ($countryCode === null && empty($crmData['BillingCountry']) === false) {
$countryCode = $this->convertCountryNameToCode($crmData['BillingCountry']);
}
if ($countryCode === null && empty($crmData['ShippingCountry']) === false) {
$countryCode = $this->convertCountryNameToCode($crmData['ShippingCountry']);
}
if (empty($crmData['Phone']) === false) {
// Trim to our width and attempt to parse it.
$number = mb_strimwidth($crmData['Phone'], 0, 25);
$parsedNumber = parsePhoneNumber($countryCode, $number);
} else {
$parsedNumber = [];
}
$industry = null;
if (empty($crmData['Industry']) === false) {
$industry = mb_strimwidth($crmData['Industry'], 0, 40);
}
$domain = null;
if (empty($crmData['Website']) === false) {
$domain = mb_strimwidth($crmData['Website'], 0, 191);
$domain = StringUtil::resolveDomain($domain);
}
$createdDate = null;
if (empty($crmData['CreatedDate']) === false) {
$createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');
}
$profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);
$data = [
'team_id' => $this->team->id,
'user_id' => $profile?->user_id,
'owner_id' => $crmData['OwnerId'],
'name' => mb_strimwidth($crmData['Name'], 0, 191),
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: $crmData['Id'],
modelType: Account::class,
fileName: $crmData['Id'],
avatarText: $crmData['Name']
),
'industry' => $industry,
'domain' => $domain,
'phone' => $parsedNumber['phone'] ?? null,
'ext' => $parsedNumber['ext'] ?? null,
'country_code' => $countryCode,
'remotely_created_at' => $createdDate,
];
$this->restoreAnyTrashedEntity($this->config->accounts(), $crmData['Id']);
/** @var Account $account */
$account = $this->config->accounts()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);
$this->handleObjectDeletion($account, $crmData);
return $account;
}
/**
* @inheritdoc
*/
public function syncOpportunities(array $parameters, ?string $strategy = null): int
{
$strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);
$syncCount = 0;
$logParams = $parameters;
$parameters['profile'] = $this->profile;
$logParams['user'] = $this->profile->getUserId();
if (count($strategies) > 1) {
$this->logger->warning('[' . $this->getDisplayName() . '] Multiple sync strategies used', [
'teamId' => $this->team->getUuid(),
'params' => $logParams,
'strategies_count' => count($strategies),
]);
}
foreach ($strategies as $syncStrategy) {
$name = $syncStrategy->getStrategyName();
try {
$sfOpportunities = $syncStrategy->fetchOpportunities($parameters);
$totalRecords = $sfOpportunities->count();
foreach ($sfOpportunities as $sfOpportunity) {
$this->importOpportunity($sfOpportunity);
$syncCount++;
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
$this->logger->warning('[' . $this->getDisplayName() . '] No opportunities found', [
'teamId' => $this->team->getUuid(),
'name' => $name,
'params' => $logParams,
'reason' => $noResultsException->getMessage(),
]);
} catch (CrmException $crmException) {
// Nothing to sync.
$this->logger->warning('[' . $this->getDisplayName() . '] Opportunity sync failed', [
'teamId' => $this->team->getUuid(),
'name' => $name,
'params' => $logParams,
'reason' => $crmException->getMessage(),
]);
}
}
$this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::OPPORTUNITY, ['params' => $logParams]);
// debug to see how if count of opportunities reaches 1000
if ($syncCount >= 1000) {
$this->logger->info(
'[' . $this->getDisplayName() . '] Sync Opportunities - count warning',
[
'team_id' => $this->team->getId(),
'params' => $logParams,
'count' => $syncCount,
'strategies_count' => count($strategies),
'total_records' => $totalRecords ?? null,
]
);
}
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncOpportunity(string $crmId): ?Opportunity
{
$strategy = $this->opportunitySyncStrategyResolver->resolve(
$this->config,
OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY
);
$parameters = [
'profile' => $this->profile,
'crm_id' => $crmId,
];
try {
$sfOpportunity = $strategy->fetchOpportunities($parameters);
} catch (HttpNotFoundException $e) {
$this->logger->info('[' . $this->getDisplayName() . '] Opportunity not found', [
'teamId' => $this->team->id_string,
'crmId' => $crmId,
]);
return null;
} catch (CrmException $crmException) {
$this->logger->info('[' . $this->getDisplayName() . '] Opportunity sync failed', [
'teamId' => $this->team->id_string,
'crmId' => $crmId,
'exception' => $crmException->getMessage(),
]);
return null;
}
if ($sfOpportunity instanceof ArrayIterator) {
return $this->importOpportunity($sfOpportunity->getItems());
}
return $this->importOpportunity($sfOpportunity);
}
/**
* @throws HttpNotFoundException
*/
private function importOpportunity($crmData): ?Opportunity
{
$profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);
$account = null;
if (empty($crmData['AccountId']) === false) {
/** @var ?Account $account */
$account = $this->config->accounts()
->where('crm_provider_id', (string) $crmData['AccountId'])
->first();
if ($account === null) {
$account = $this->syncAccount($crmData['AccountId']);
}
}
$userId = $profile?->getUserId() ?? $account?->getUserId();
if ($userId...
|
[{"role":"AXStaticText","text& [{"role":"AXStaticText","text":"Diff","depth":1,"bounds":{"left":0.25531915,"top":0.39185953,"width":0.013962766,"height":0.013567438},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Changelist:","depth":1,"bounds":{"left":0.38231382,"top":0.15802075,"width":0.022938829,"height":0.027134877},"on_screen":true,"role_description":"text"},{"role":"AXPopUpButton","text":"Changelist:","depth":1,"bounds":{"left":0.40658244,"top":0.15802075,"width":0.042220745,"height":0.027134877},"on_screen":true,"role_description":"pop up button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Changes","depth":6,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Show Diff","depth":2,"bounds":{"left":0.25664893,"top":0.16201118,"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":"Rollback...","depth":2,"bounds":{"left":0.26529256,"top":0.16201118,"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":"Refresh","depth":2,"bounds":{"left":0.27393618,"top":0.16201118,"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":"Group By","depth":2,"bounds":{"left":0.28490692,"top":0.16201118,"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":"Expand All","depth":2,"bounds":{"left":0.3617021,"top":0.16201118,"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":"Collapse All","depth":2,"bounds":{"left":0.37034574,"top":0.16201118,"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":"AXStaticText","text":"1 modified","depth":1,"bounds":{"left":0.25531915,"top":0.254589,"width":0.1931516,"height":0.016759777},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"J","depth":2,"bounds":{"left":0.2556516,"top":0.3008779,"width":0.19281915,"height":0.087789305},"on_screen":true,"value":"J","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Commit Message","depth":1,"bounds":{"left":0.25531915,"top":0.28252193,"width":0.03557181,"height":0.013567438},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Commit Message History","depth":2,"bounds":{"left":0.4401596,"top":0.27853152,"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":"AXStaticText","text":"Git","depth":2,"bounds":{"left":0.45212767,"top":0.16280925,"width":0.0056515955,"height":0.013567438},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Author:","depth":2,"bounds":{"left":0.46010637,"top":0.18834797,"width":0.014960106,"height":0.013567438},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"Author:","depth":3,"bounds":{"left":0.4787234,"top":0.18834797,"width":0.09840426,"height":0.013567438},"on_screen":true,"role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Amend commit","depth":2,"bounds":{"left":0.4587766,"top":0.21069433,"width":0.039893616,"height":0.019952115},"on_screen":true,"help_text":"Merge this commit with the previous one","role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Sign-off commit","depth":2,"bounds":{"left":0.4587766,"top":0.23383878,"width":0.042220745,"height":0.019952115},"on_screen":true,"help_text":"<html>Adds the following line at the end of the commit message:<br/>Signed-off by: Lukas Kovalik <kovaliklukas@gmail.com></html>","role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Create extra commit with file movements","depth":2,"on_screen":false,"role_description":"checkbox","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Commit Checks","depth":2,"bounds":{"left":0.45212767,"top":0.27613726,"width":0.032247342,"height":0.013567438},"on_screen":true,"role_description":"text"},{"role":"AXCheckBox","text":"Update copyright","depth":2,"bounds":{"left":0.4587766,"top":0.29608938,"width":0.04488032,"height":0.019952115},"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Reformat code","depth":2,"bounds":{"left":0.4587766,"top":0.31923383,"width":0.03956117,"height":0.019952115},"on_screen":true,"help_text":"","role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Rearrange code","depth":2,"bounds":{"left":0.4587766,"top":0.3423783,"width":0.041888297,"height":0.019952115},"on_screen":true,"help_text":"","role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Optimize imports","depth":2,"bounds":{"left":0.4587766,"top":0.36552274,"width":0.044215426,"height":0.019952115},"on_screen":true,"help_text":"","role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Cleanup","depth":2,"bounds":{"left":0.4587766,"top":0.3886672,"width":0.026263298,"height":0.019952115},"on_screen":true,"help_text":"","role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"Choose profile","depth":2,"bounds":{"left":0.48902926,"top":0.39185953,"width":0.029920213,"height":0.013567438},"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Check malicious dependencies","depth":2,"bounds":{"left":0.4587766,"top":0.41181165,"width":0.07247341,"height":0.019952115},"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Run external formatter","depth":2,"bounds":{"left":0.4587766,"top":0.4349561,"width":0.05518617,"height":0.019952115},"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"Configure","depth":2,"bounds":{"left":0.51795214,"top":0.43814844,"width":0.019946808,"height":0.013567438},"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Perform SonarQube for IDE analysis","depth":2,"bounds":{"left":0.4587766,"top":0.45810056,"width":0.1200133,"height":0.019952115},"on_screen":true,"help_text":"","role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Find unused view files","depth":2,"bounds":{"left":0.4587766,"top":0.481245,"width":0.054521278,"height":0.019952115},"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Run Git hooks","depth":2,"on_screen":false,"help_text":"If unchecked, Git hooks will be skipped with the '--no-verify' option for the upcoming commit","role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Advanced Commit Checks","depth":2,"bounds":{"left":0.45212767,"top":0.5235435,"width":0.05418883,"height":0.013567438},"on_screen":true,"role_description":"text"},{"role":"AXCheckBox","text":"Analyze code","depth":2,"bounds":{"left":0.4587766,"top":0.5434956,"width":0.036901597,"height":0.019952115},"on_screen":true,"help_text":"","role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"Choose profile","depth":2,"bounds":{"left":0.49966756,"top":0.54668796,"width":0.029920213,"height":0.013567438},"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Check TODO","depth":2,"bounds":{"left":0.4587766,"top":0.5666401,"width":0.035904255,"height":0.019952115},"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"Configure","depth":2,"bounds":{"left":0.49867022,"top":0.5698324,"width":0.019946808,"height":0.013567438},"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"After Commit","depth":2,"bounds":{"left":0.45212767,"top":0.6089386,"width":0.027260639,"height":0.013567438},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Upload files to:","depth":2,"bounds":{"left":0.46010637,"top":0.6360734,"width":0.030585106,"height":0.013567438},"on_screen":true,"role_description":"text"},{"role":"AXPopUpButton","text":"Upload files to:","depth":2,"bounds":{"left":0.49168882,"top":0.62968874,"width":0.07679521,"height":0.027134877},"on_screen":true,"role_description":"pop up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"<None>","depth":7,"on_screen":false,"role_description":"text"},{"role":"AXCheckBox","text":"Always use selected server or group of servers","depth":2,"bounds":{"left":0.46675533,"top":0.66081405,"width":0.10538564,"height":0.019952115},"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Previous Difference","depth":2,"bounds":{"left":0.25664893,"top":0.41340783,"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 Difference","depth":2,"bounds":{"left":0.26529256,"top":0.41340783,"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":"Jump to Source","depth":2,"bounds":{"left":0.27393618,"top":0.41340783,"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":"Compare Previous File","depth":2,"bounds":{"left":0.28490692,"top":0.41340783,"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":"Compare Next File","depth":2,"bounds":{"left":0.29355052,"top":0.41340783,"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":"Go to Changed File…","depth":2,"bounds":{"left":0.30219415,"top":0.41340783,"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":"Side-by-side viewer","depth":2,"bounds":{"left":0.3118351,"top":0.41340783,"width":0.04720745,"height":0.01915403},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Do not ignore","depth":2,"bounds":{"left":0.36336437,"top":0.41340783,"width":0.03557181,"height":0.01915403},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Highlight words","depth":2,"bounds":{"left":0.40093085,"top":0.41340783,"width":0.03956117,"height":0.01915403},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse Unchanged Fragments","depth":2,"bounds":{"left":0.44148937,"top":0.41340783,"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":"Synchronize Scrolling","depth":2,"bounds":{"left":0.45013297,"top":0.41340783,"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":"Disable Editing","depth":2,"bounds":{"left":0.4587766,"top":0.41340783,"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":"Settings","depth":2,"bounds":{"left":0.46742022,"top":0.41340783,"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":"Help","depth":2,"bounds":{"left":0.47839096,"top":0.41340783,"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":"AXStaticText","text":"4 differences","depth":1,"bounds":{"left":0.5511968,"top":0.41181165,"width":0.027260639,"height":0.022346368},"on_screen":true,"role_description":"text"},{"role":"AXTextField","text":"1aee7aad","depth":1,"bounds":{"left":0.26196808,"top":0.43415803,"width":0.022606382,"height":0.013567438},"on_screen":true,"value":"1aee7aad","help_text":"text/plain","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Salesforce;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse Illuminate\\Database\\Eloquent\\Relations\\HasMany;\nuse Illuminate\\Events\\Dispatcher;\nuse Illuminate\\Support\\Facades\\Cache;\nuse Illuminate\\Support\\Facades\\Log;\nuse Illuminate\\Support\\Str;\nuse Jiminny\\Component\\Country\\CountriesMap;\nuse Jiminny\\Contracts\\Acl\\PermissionEnum;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\FetchRelatedActivityInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\ImportsBusinessProcessesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\SalesforceBatchSyncInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\SalesforceInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteNoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SearchTaskInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SendSummaryToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SupportsObjectTypeParseInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmProfileRecordTypesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Enums\\CrmObject;\nuse Jiminny\\Events\\Activities\\Crm\\LeadConverted;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpBadRequestException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Exceptions\\NoResultsException;\nuse Jiminny\\Exceptions\\ServiceUnavailableException;\nuse Jiminny\\Jobs\\Crm\\NoteObject;\nuse Jiminny\\Jobs\\Crm\\MatchActivitiesToNewOpportunity;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Calendar\\CalendarEvent;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Contracts\\ActivityContract;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Configuration;\nuse Jiminny\\Models\\Crm\\ContactRole;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Crm\\RecordType;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\TeamSettings;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\ContactRoleRepository;\nuse Jiminny\\Repositories\\Crm\\FieldRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Repositories\\Crm\\RecordTypeFieldValuesRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\Helpers\\ArrayIterator;\nuse Jiminny\\Services\\Crm\\MatchDomainByEmailInterface;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Services\\Crm\\ResolveCompanyNameByEmailTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\Fields\\FieldHelper;\nuse Jiminny\\Services\\Crm\\Salesforce\\Fields\\FieldTypeConverter;\nuse Jiminny\\Services\\Crm\\Salesforce\\Fields\\ValueNormalizer;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\FollowupActivityTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\LogActivityTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\RecordManipulationsTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\SyncFieldsTrait;\nuse Jiminny\\Utils\\CurrencyFormatter;\nuse Jiminny\\Utils\\StringUtil;\nuse Ramsey\\Uuid\\Uuid;\nuse Sentry\\Laravel\\Facade as Sentry;\n\nclass Service extends BaseService implements\n SalesforceInterface,\n SalesforceBatchSyncInterface,\n SyncCrmEntitiesInterface,\n SyncCrmProfileRecordTypesInterface,\n ImportsBusinessProcessesInterface,\n RemoteEntityManipulationInterface,\n FetchRelatedActivityInterface,\n SendSummaryToCrmInterface,\n MatchDomainByEmailInterface,\n SearchTaskInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n SupportsObjectTypeParseInterface,\n RemoteNoteEntityManipulationInterface,\n VerifyTaskExistsInterface\n{\n use ResolveCompanyNameByEmailTrait;\n use SyncFieldsTrait;\n use DeleteObjectsTrait;\n use RecordManipulationsTrait;\n use ServiceTraits\\BatchSyncTrait;\n use FollowupActivityTrait;\n use LogActivityTrait;\n\n /**\n * Note Body Limit for the Old Note-Taking Tool\n *\n * @var int\n */\n private const int CLASSIC_NOTE_MAX_LENGTH = 32000;\n\n /**\n * Note Content Limit for the New Notes\n *\n * @var int\n */\n private const int ENHANCED_NOTE_MAX_LENGTH = 50000000;\n\n private const string INSTALLED_PACKAGE_ID = '033Tw0000007bKbIAI';\n\n private const int CACHE_TTL = 600;\n\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day - 86400\n\n /**\n * @var Client\n */\n protected $client;\n\n protected PayloadBuilder $payloadBuilder;\n protected QueryHandler $queryHandler;\n\n private OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n\n public function __construct(\n Client $client,\n PayloadBuilder $payloadBuilder,\n protected Dispatcher $eventDispatcher,\n private readonly CountriesMap $countriesMap,\n private readonly ProspectPhotoPathService $prospectPhotoPathService,\n ) {\n parent::__construct();\n\n $this->client = $client;\n $this->payloadBuilder = $payloadBuilder;\n $this->queryHandler = app(QueryHandler::class, [\n 'client' => $this->client,\n 'logger' => $this->logger,\n ]);\n $this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n }\n\n public function getDisplayName(): string\n {\n return 'Salesforce';\n }\n\n public function getJobDelay(): int\n {\n return 1;\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n return $user->getSocialAccount(SocialAccount::PROVIDER_SALESFORCE);\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($activity, $crmProviderId) {\n $playbook = $this->getPlaybookFromActivity($activity);\n\n if ($playbook === null) {\n $this->logger->warning('[Salesforce] Cannot verify task - no playbook found', [\n 'activity' => $activity->getId(),\n 'crm_provider_id' => $crmProviderId,\n ]);\n\n return false;\n }\n\n $objectType = $playbook->getActivityType() === Playbook::ACTIVITY_TYPE_EVENT ? 'Event' : 'Task';\n\n try {\n $record = $this->getRecord($objectType, $crmProviderId, ['Id', 'IsDeleted']);\n\n return ! empty($record) && ($record['IsDeleted'] ?? false) === false;\n } catch (HttpNotFoundException|HttpBadRequestException) {\n $this->logger->info('[Salesforce] Activity record not found during verification', [\n 'activity' => $activity->getId(),\n 'object_type' => $objectType,\n 'crm_provider_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n });\n }\n\n public function query(string $queryToRun, array $parameters = []): QueryIterator\n {\n // Due to poorly designed external calls, this method cannot be entirely removed\n return $this->queryHandler->query($queryToRun, $parameters);\n }\n\n /*=========== Organization Information ===============*/\n\n /**\n * Get a list of all the API Versions for the instance.\n *\n * @throws CrmException\n *\n * @return mixed\n *\n */\n public function getApiVersions()\n {\n $url = $this->config->crm_base_url . '/services/data';\n\n $response = $this->client->get($url);\n\n return json_decode($response->getBody(), true);\n }\n\n /**\n * Gets the valid recordTypes for a given Salesforce Object via the describe API.\n */\n private function getRecordTypes(string $crmObject): array\n {\n $url = $this->client->getObjectsUrl() . $crmObject . '/describe';\n\n $response = $this->client->get($url);\n $jsonResponse = json_decode($response->getBody(), true);\n\n $fields = [];\n foreach ($jsonResponse['recordTypeInfos'] as $row) {\n $fields[] = ['recordTypeId' => $row['recordTypeId'], 'default' => $row['defaultRecordTypeMapping']];\n }\n\n return $fields;\n }\n\n /**\n * Convert raw field data into a format compatible with CRM APIs.\n */\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return ValueNormalizer::normalize($fieldType, $fieldValue, $internal);\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n $defaultFields = ($activityType === Playbook::ACTIVITY_TYPE_TASK)\n ? FieldDefinitions::defaultTaskFields()\n : FieldDefinitions::defaultEventFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n // Setup the activity field as the default Type.\n /** @var Field $activityField */\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'Type',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK, Playbook::ACTIVITY_TYPE_EVENT];\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n\n $fieldFilter = ($activityType === Playbook::ACTIVITY_TYPE_TASK)\n ? FieldDefinitions::taskFollowupFieldsFilter()\n : FieldDefinitions::eventFollowupFieldsFilter();\n\n foreach ($fieldFilter as $eachFilter) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $eachFilter);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n /**\n * This one is now called only when ImportActivityTypes is triggered or SyncFieldMetadata executed manually\n * Regular sync now uses SharedSyncFieldsTrait -> syncSingleObjectType\n * Needs to be replaced later on\n */\n public function syncField(Field $field): void\n {\n try {\n if (FieldHelper::isCustomField($field)) {\n $query = '\n SELECT\n Id, Metadata, TableEnumOrId\n FROM\n CustomField\n WHERE\n DeveloperName = :fieldName\n AND\n TableEnumOrId = :fieldType\n AND\n NamespacePrefix = :namespacePrefix';\n\n // We need to constrain the field lookup to the object, in case it's used in multiple places.\n $objectType = \\in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true)\n ? 'activity'\n : $field->object_type;\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'fieldName' => substr($field->crm_provider_id, 0, -\\strlen('__c')),\n 'fieldType' => ucfirst($objectType),\n\n // This is used to ensure we only consider the field within the org, not installed packages.\n 'namespacePrefix' => 'null',\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n // Sync field metadata.\n $metadata = $sfField['Metadata'];\n\n $field->description = mb_strimwidth($metadata['description'] ?? '', 0, 191);\n $field->label = mb_strimwidth($metadata['label'] ?? '', 0, Field::LABEL_MAX_LENGTH);\n $field->type = $this->convertFieldType($metadata['type'], $field->getEntityName());\n $field->is_mandatory = ($metadata['required'] === true);\n $field->length = $metadata['length'];\n $field->default_value = mb_strimwidth(trim($metadata['defaultValue'] ?? '', '\"'), 0, 191);\n $field->save();\n } else {\n $query = '\n SELECT\n Id, DataType, DeveloperName, Label, Length, Description\n FROM\n FieldDefinition\n WHERE\n DurableId = :entityName';\n\n $entityName = $field->getEntityName();\n $sfFields = $this->queryHandler->metadata($query, [\n 'entityName' => $entityName,\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n $convertedType = $this->convertFieldType($sfField['DataType'], $entityName);\n $label = mb_strimwidth($sfField['Label'], 0, Field::LABEL_MAX_LENGTH);\n\n if ($field->isBusinessType()) {\n $label = 'Opportunity Type';\n }\n\n $field->description = mb_strimwidth($sfField['Description'], 0, Field::DESCRIPTION_MAX_LENGTH);\n $field->label = $label;\n $field->type = $convertedType;\n $field->length = $sfField['Length'];\n $field->save();\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n private function convertFieldType(string $from, ?string $entityName = null): string\n {\n $converter = new FieldTypeConverter();\n\n return $converter->convert($from, $entityName);\n }\n\n /**\n * @inheritdoc\n */\n public function importPicklistValues(Field $field): array\n {\n $values = [];\n $fieldValues = [];\n\n try {\n if (FieldHelper::isCustomField($field)) {\n $query = '\n SELECT\n Id, Metadata, TableEnumOrId\n FROM\n CustomField\n WHERE\n DeveloperName = :fieldName\n AND\n TableEnumOrId = :fieldType\n AND\n NamespacePrefix = :namespacePrefix';\n\n // We need to constrain the field lookup to the object, in case it's used in multiple places.\n $objectType = \\in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true) ?\n 'activity' : $field->object_type;\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'fieldName' => substr($field->crm_provider_id, 0, -\\strlen('__c')),\n 'fieldType' => ucfirst($objectType),\n // This is used to ensure we only consider the field within the org, not installed packages.\n 'namespacePrefix' => 'null',\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n $valueSet = $sfField['Metadata']['valueSet'];\n\n if ($valueSet['valueSetName'] === null) {\n // Local picklist values can be obtained easily.\n $picklistValues = $valueSet['valueSetDefinition']['value'];\n } else {\n // But for some fields, we just get the Global Value Picklist pointer so need to do more work.\n $picklistValues = $this->importGlobalValuePicklistValues($valueSet['valueSetName']);\n }\n\n // Import all active values.\n foreach ($picklistValues as $i => $sfFieldValue) {\n // Setup default value.\n if ($sfFieldValue['default']) {\n $field->update(['default_value' => $sfFieldValue['valueName']]);\n }\n\n // This comes through as null if active (lol).\n if ($sfFieldValue['isActive'] !== false) {\n $values[] = [\n 'value' => $sfFieldValue['valueName'],\n 'label' => $sfFieldValue['valueName'],\n 'sequence' => $i,\n 'is_default' => $sfFieldValue['default'],\n ];\n }\n }\n } else {\n $objectFields = $this->getObjectFields($field->object_type);\n $fieldId = $field->crm_provider_id;\n\n // Only work with our field of interest.\n $objectField = array_filter($objectFields, function ($item) use ($fieldId) {\n return $item['name'] === $fieldId;\n });\n\n $objectField = array_shift($objectField);\n if (empty($objectField['picklistValues']) === false) {\n foreach ($objectField['picklistValues'] as $i => $sfFieldValue) {\n // Skip inactive values.\n if ($sfFieldValue['active'] === false) {\n continue;\n }\n\n // Setup default value.\n if ($sfFieldValue['defaultValue']) {\n $field->update(['default_value' => $sfFieldValue['value']]);\n }\n\n $values[] = [\n 'value' => $sfFieldValue['value'],\n 'label' => $sfFieldValue['label'],\n 'sequence' => $i,\n 'is_default' => $sfFieldValue['defaultValue'],\n ];\n }\n }\n }\n\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n $value['value'] = substr($value['value'] ?? '', 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n if (($key = array_search($value['value'], $fieldsToPurge, true)) !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n // Get IDs of the values to be deleted\n $valuesToDelete = $field->values()->whereIn('value', $fieldsToPurge);\n $valuesToDeleteIds = $valuesToDelete->pluck('id');\n if (! $valuesToDeleteIds->isEmpty()) {\n $recordTypeFieldValuesRepository = app(RecordTypeFieldValuesRepository::class);\n $recordTypeFieldValuesRepository->deleteForCrmFieldValueIds($valuesToDeleteIds->toArray());\n\n // Now safely delete from crm_field_values\n $valuesToDelete->delete();\n }\n\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n return $fieldValues;\n }\n\n /**\n * Gets values from Global Value Picklists.\n */\n private function importGlobalValuePicklistValues(string $picklistName): array\n {\n $query = '\n SELECT\n Metadata\n FROM\n GlobalValueSet\n WHERE\n DeveloperName = :picklistName\n LIMIT 1';\n\n try {\n $sfValues = $this->queryHandler->metadata($query, [\n 'picklistName' => $picklistName,\n ]);\n\n // There is always 1 result at this point.\n $sfValue = $sfValues->current();\n\n return $sfValue['Metadata']['customValue'];\n } catch (NoResultsException $noResultsException) {\n // Nothing returned.\n\n return [];\n }\n }\n\n /**\n * @inheritdoc\n */\n public function syncProfileRecordTypes(): void\n {\n $objectTypes = [\n 'lead',\n 'account',\n 'contact',\n 'opportunity',\n 'task',\n 'event',\n ];\n\n foreach ($objectTypes as $objectType) {\n try {\n $crmRecordTypes = $this->getRecordTypes(ucfirst($objectType));\n\n foreach ($crmRecordTypes as $crmRecordType) {\n // If the record type is default and not the Master type, set this.\n if ($crmRecordType['default'] && $crmRecordType['recordTypeId'] !== '012000000000000AAA') {\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $crmRecordType['recordTypeId'])\n ->first();\n\n if ($recordType) {\n $this->profile->{$objectType . '_record_type_id'} = $recordType->id;\n }\n }\n }\n } catch (HttpNotFoundException $exception) {\n Log::error('No access to ' . $objectType . ' object, skipping...');\n\n // XXX: should we log this fact somewhere?\n continue;\n }\n }\n\n if ($this->profile->isDirty()) {\n $this->profile->save();\n }\n }\n\n /**\n * Gets business processes.\n */\n public function importBusinessProcesses(): void\n {\n $query = '\n SELECT\n Id, IsActive, Name, TableEnumOrId\n FROM\n BusinessProcess\n WHERE\n TableEnumOrId IN (\\'Lead\\',\\'Opportunity\\')';\n\n try {\n $sfProcesses = $this->queryHandler->query($query);\n\n // Upsert all processes for the team.\n foreach ($sfProcesses as $sfProcess) {\n /** @var BusinessProcess $businessProcess */\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $sfProcess['Id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => $sfProcess['Name'],\n 'type' => $sfProcess['TableEnumOrId'] === 'Lead' ? 'lead' : 'opportunity',\n 'is_selectable' => $sfProcess['IsActive'],\n ]);\n\n $this->importBusinessProcessStages($businessProcess);\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n /**\n * Gets business process stages.\n */\n private function importBusinessProcessStages(BusinessProcess $businessProcess): void\n {\n $query = '\n SELECT\n Metadata\n FROM\n BusinessProcess\n WHERE\n Id = :processId';\n\n try {\n $stages = [];\n $sfProcessStages = $this->queryHandler->metadata($query, [\n 'processId' => $businessProcess->crm_provider_id,\n ]);\n\n // There is always 1 result at this point.\n $sfProcessStage = $sfProcessStages->current();\n\n // Upsert all processes for the team.\n foreach ($sfProcessStage['Metadata']['values'] as $sfProcessStage) {\n $sanitizedName = urldecode($sfProcessStage['valueName']); // Must decode: \"%2C\" becomes \",\" etc.\n\n $stage = $businessProcess->crm->stages()\n // This MUST match on label because this API doesn't use API Name.\n ->where('label', $sanitizedName)\n ->where('type', $businessProcess->type)\n ->where('is_selectable', 1)\n ->first();\n\n if ($stage) {\n $stages[] = $stage->id;\n }\n }\n\n $businessProcess->stages()->sync($stages);\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n /**\n * Gets record types.\n */\n public function importRecordTypes(): void\n {\n $query = '\n SELECT\n Id, IsActive, Name, BusinessProcessId, SobjectType\n FROM\n RecordType';\n\n try {\n $sfRecordTypes = $this->queryHandler->query($query);\n\n // Upsert all record types for the process.\n foreach ($sfRecordTypes as $sfRecordType) {\n $businessProcess = null;\n if ($sfRecordType['BusinessProcessId']) {\n $businessProcess = $this->config->businessProcesses()\n ->where('crm_provider_id', $sfRecordType['BusinessProcessId'])\n ->first();\n }\n\n /** @var RecordType $recordType */\n $recordType = $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $sfRecordType['Id'],\n ], [\n 'team_id' => $this->team->id,\n 'type' => mb_strtolower($sfRecordType['SobjectType']),\n 'name' => $sfRecordType['Name'],\n 'is_selectable' => $sfRecordType['IsActive'],\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n $this->importRecordTypeFieldValues($recordType);\n }\n } catch (NoResultsException $noResultsException) {\n // Do nothing.\n }\n }\n\n /**\n * Import record type - field value mappings. This only works for standard fields.\n */\n private function importRecordTypeFieldValues(RecordType $recordType): void\n {\n try {\n $query = '\n SELECT\n Metadata\n FROM\n RecordType\n WHERE\n Id = :recordTypeId';\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'recordTypeId' => $recordType->crm_provider_id,\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n // Sync field metadata.\n $picklists = $sfField['Metadata']['picklistValues'];\n\n foreach ($picklists as $picklist) {\n $field = $this->config->fields()->where([\n 'type' => Field::TYPE_PICKLIST,\n 'object_type' => $recordType->type,\n 'crm_provider_id' => $picklist['picklist'],\n ])->first();\n\n if ($field) {\n $fieldValues = [];\n\n foreach ($picklist['values'] as $value) {\n // Must decode: \"%2C\" becomes \",\" etc.\n $fieldValue = $field->values()\n ->where('value', urldecode($value['valueName']))\n ->first();\n\n if ($fieldValue) {\n $fieldValues[] = $fieldValue->id;\n }\n }\n\n $recordType->fieldValues()->sync($fieldValues);\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n /**\n * @inheritdoc\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $params = [];\n $missingStage = null;\n if ($types === null) {\n $types = [Stage::TYPE_LEAD, Stage::TYPE_OPPORTUNITY];\n }\n\n foreach ($types as $type) {\n if ($type === Stage::TYPE_LEAD) {\n $query = '\n SELECT\n Id, ApiName, MasterLabel, SortOrder\n FROM\n LeadStatus';\n } else {\n $query = '\n SELECT\n Id, ApiName, MasterLabel, IsActive, SortOrder, DefaultProbability\n FROM\n OpportunityStage';\n }\n\n if ($missingStageName) {\n $escapedStageName = ValueNormalizer::replaceQueryWithStringLiterals($missingStageName);\n\n $query .= ' WHERE ApiName = :stageName';\n\n $params = [\n 'stageName' => $escapedStageName,\n ];\n }\n\n try {\n $sfStages = $this->queryHandler->query($query, $params);\n } catch (NoResultsException $exception) {\n $sfStages = [];\n }\n\n $missingStage = null;\n\n // Upsert all stages for the team.\n foreach ($sfStages as $sfStage) {\n $selectable = true;\n if (array_key_exists('IsActive', $sfStage)) {\n $selectable = $sfStage['IsActive'];\n }\n\n $this->restoreAnyTrashedEntity($this->config->stages(), $sfStage['Id']);\n\n $stage = $this->config->stages()->updateOrCreate([\n 'crm_provider_id' => $sfStage['Id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($sfStage['ApiName'], 0, 50),\n 'label' => mb_strimwidth($sfStage['MasterLabel'], 0, 191),\n 'type' => $type,\n 'sequence' => $sfStage['SortOrder'] ?? 0,\n 'is_selectable' => $selectable,\n 'probability' => $sfStage['DefaultProbability'] ?? null,\n ]);\n\n if ($missingStageName && $missingStageName === $sfStage['ApiName']) {\n $missingStage = $stage;\n }\n }\n\n if ($missingStageName && $missingStage === null) {\n // If they requested a stage that still doesn't exist, it must be inactive so lazy create it.\n $missingStage = $this->config->stages()->create([\n 'crm_provider_id' => Uuid::uuid4(),\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($missingStageName, 0, 50),\n 'label' => mb_strimwidth($missingStageName, 0, 191),\n 'type' => $type,\n 'sequence' => 0,\n 'is_selectable' => 0,\n ]);\n }\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int\n {\n $syncCount = 0;\n $fields = $this->getAllFieldsAsArray('lead');\n if (\\in_array('Id', $fields, true) === false) {\n return $syncCount;\n }\n\n $query = '\n SELECT ' . rtrim(implode(',', $fields), ',') . '\n FROM Lead\n WHERE LastModifiedDate > :since\n ORDER BY LastModifiedDate ASC';\n\n try {\n $sfLeads = $this->queryHandler->query($query, [\n 'since' => $since->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($sfLeads as $sfLead) {\n // Only sync if previously imported.\n if ($this->hasLead($sfLead['Id'])) {\n $this->importLead($sfLead);\n $syncCount++;\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::LEAD);\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLead(string $crmId): ?Lead\n {\n $fields = $this->getAllFieldsAsArray('lead');\n\n $sfLead = $this->getRecord('Lead', $crmId, $fields);\n\n return $this->importLead($sfLead);\n }\n\n private function importLead($crmData): ?Lead\n {\n /** @var ?Stage $stage */\n $stage = null;\n if (isset($crmData['Status'])) {\n // Get the current stage.\n $stage = $this->config\n ->stages()\n ->where('name', $crmData['Status'])\n ->where('type', Stage::TYPE_LEAD)\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_LEAD], $crmData['Status']);\n }\n }\n\n // If we have no way of importing this, just return null :(\n if ($stage === null) {\n return null;\n }\n\n $countryCode = $crmData['CountryCode'] ?? null;\n\n // Salesforce allows custom \"countries\" to be created. Disregard these.\n if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {\n $countryCode = null;\n }\n\n // If we have no country code, try to parse it from the country name.\n if ($countryCode === null && empty($crmData['Country']) !== false) {\n $countryCode = $this->convertCountryNameToCode($crmData['Country']);\n }\n\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($crmData['Phone'] ?? '', 0, 25);\n $parsedNumber = parsePhoneNumber($countryCode, $number);\n\n $mobilePhone = null;\n if (empty($crmData['MobilePhone']) === false) {\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($crmData['MobilePhone'], 0, 25);\n $mobilePhone = phone_e164($countryCode, $number);\n }\n\n $convertedDate = null;\n $convertedAccount = null;\n $convertedOpportunity = null;\n $convertedContact = null;\n\n if ($crmData['IsConverted'] == 'true') {\n $convertedDate = $crmData['ConvertedDate'];\n\n if (empty($crmData['ConvertedAccountId']) === false) {\n $convertedAccount = $this->config\n ->accounts()\n ->where('crm_provider_id', $crmData['ConvertedAccountId'])\n ->first();\n\n if ($convertedAccount === null) {\n try {\n $convertedAccount = $this->syncAccount($crmData['ConvertedAccountId']);\n } catch (HttpNotFoundException $exception) {\n // Probably the user has no permissions to access the converted data.\n }\n }\n }\n\n if (empty($crmData['ConvertedOpportunityId']) === false) {\n $convertedOpportunity = $this->config\n ->opportunities()\n ->where('crm_provider_id', $crmData['ConvertedOpportunityId'])\n ->first();\n\n if ($convertedOpportunity === null) {\n try {\n $convertedOpportunity = $this->syncOpportunity($crmData['ConvertedOpportunityId']);\n } catch (HttpNotFoundException $exception) {\n // Probably the user has no permissions to access the converted data.\n }\n }\n }\n\n if (empty($crmData['ConvertedContactId']) === false) {\n $convertedContact = $this->team\n ->crm\n ->contacts()\n ->where('crm_provider_id', $crmData['ConvertedContactId'])\n ->first();\n\n if ($convertedContact === null) {\n try {\n $convertedContact = $this->syncContact($crmData['ConvertedContactId']);\n } catch (HttpNotFoundException $exception) {\n // Probably the user has no permissions to access the converted data.\n }\n }\n }\n }\n\n if (empty($crmData['Company'])) {\n $company = 'Unknown';\n } else {\n $company = mb_strimwidth($crmData['Company'], 0, 191);\n }\n\n $domain = null;\n if (empty($crmData['Website']) === false) {\n $domain = mb_strimwidth($crmData['Website'], 0, 191);\n $domain = StringUtil::resolveDomain($domain);\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $data = [\n 'team_id' => $this->team->id,\n 'user_id' => $profile?->user_id,\n 'owner_id' => $crmData['OwnerId'] ?? '',\n 'company' => $company,\n 'domain' => $domain,\n 'name' => $crmData['Name'] ? mb_strimwidth($crmData['Name'], 0, 191) : '',\n 'title' => $crmData['Title'] ? mb_strimwidth($crmData['Title'], 0, 128) : null,\n 'email' => $crmData['Email'] ? mb_strimwidth($crmData['Email'], 0, 80) : null,\n 'phone' => $parsedNumber['phone'],\n 'ext' => $parsedNumber['ext'] ?? null,\n 'mobile_phone' => $mobilePhone,\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['Id'],\n modelType: Lead::class,\n fileName: $crmData['Id'],\n avatarText: $crmData['Name']\n ),\n 'stage_id' => $stage->id,\n 'record_type_id' => null,\n 'converted_at' => $convertedDate,\n 'converted_account_id' => $convertedAccount->id ?? null,\n 'converted_opportunity_id' => $convertedOpportunity->id ?? null,\n 'converted_contact_id' => $convertedContact->id ?? null,\n 'country_code' => $countryCode,\n 'remotely_created_at' => $createdDate,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->leads(), $crmData['Id']);\n\n /** @var Lead $lead */\n $lead = $this->config->leads()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n if ($lead->wasChanged('converted_at') && $lead->getConvertedAt() !== null) {\n $this->eventDispatcher->dispatch(new LeadConverted($lead));\n }\n\n $this->handleObjectDeletion($lead, $crmData);\n\n return $lead;\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccounts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n $fields = $this->getAllFieldsAsArray('account');\n\n if (\\in_array('Id', $fields, true) === false) {\n return $syncCount;\n }\n\n $query = '\n SELECT ' . rtrim(implode(',', $fields), ',') . '\n FROM Account\n WHERE LastModifiedDate > :since\n ORDER BY LastModifiedDate ASC';\n\n try {\n $sfAccounts = $this->queryHandler->query($query, [\n 'since' => $since->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($sfAccounts as $sfAccount) {\n // Only sync if previously imported.\n if ($this->hasAccount($sfAccount['Id'])) {\n $this->importAccount($sfAccount);\n $syncCount++;\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::ACCOUNT);\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccount(string $crmId): ?Account\n {\n $fields = $this->getAllFieldsAsArray('account');\n if (! in_array('Id', $fields, true)) {\n $this->logger->info('[Salesforce] Sync account cancelled. Fields are not available.', [\n 'crmId' => $crmId,\n 'userId' => $this->profile->getUserId(),\n ]);\n\n return null;\n }\n\n $sfAccount = $this->getRecord('Account', $crmId, $fields);\n\n return $this->importAccount($sfAccount);\n }\n\n private function importAccount($crmData): Account\n {\n $countryCode = $crmData['BillingCountryCode'] ?? $crmData['ShippingCountryCode'] ?? null;\n\n // Salesforce allows custom \"countries\" to be created. Disregard these.\n if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {\n $countryCode = null;\n }\n\n // If we have no country code, try to parse it from the country names.\n if ($countryCode === null && empty($crmData['BillingCountry']) === false) {\n $countryCode = $this->convertCountryNameToCode($crmData['BillingCountry']);\n }\n\n if ($countryCode === null && empty($crmData['ShippingCountry']) === false) {\n $countryCode = $this->convertCountryNameToCode($crmData['ShippingCountry']);\n }\n\n if (empty($crmData['Phone']) === false) {\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($crmData['Phone'], 0, 25);\n $parsedNumber = parsePhoneNumber($countryCode, $number);\n } else {\n $parsedNumber = [];\n }\n\n $industry = null;\n if (empty($crmData['Industry']) === false) {\n $industry = mb_strimwidth($crmData['Industry'], 0, 40);\n }\n\n $domain = null;\n if (empty($crmData['Website']) === false) {\n $domain = mb_strimwidth($crmData['Website'], 0, 191);\n $domain = StringUtil::resolveDomain($domain);\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $data = [\n 'team_id' => $this->team->id,\n 'user_id' => $profile?->user_id,\n 'owner_id' => $crmData['OwnerId'],\n 'name' => mb_strimwidth($crmData['Name'], 0, 191),\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['Id'],\n modelType: Account::class,\n fileName: $crmData['Id'],\n avatarText: $crmData['Name']\n ),\n 'industry' => $industry,\n 'domain' => $domain,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'ext' => $parsedNumber['ext'] ?? null,\n 'country_code' => $countryCode,\n 'remotely_created_at' => $createdDate,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->accounts(), $crmData['Id']);\n\n /** @var Account $account */\n $account = $this->config->accounts()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n $this->handleObjectDeletion($account, $crmData);\n\n return $account;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOpportunities(array $parameters, ?string $strategy = null): int\n {\n $strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);\n\n $syncCount = 0;\n $logParams = $parameters;\n $parameters['profile'] = $this->profile;\n $logParams['user'] = $this->profile->getUserId();\n\n if (count($strategies) > 1) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Multiple sync strategies used', [\n 'teamId' => $this->team->getUuid(),\n 'params' => $logParams,\n 'strategies_count' => count($strategies),\n ]);\n }\n\n foreach ($strategies as $syncStrategy) {\n $name = $syncStrategy->getStrategyName();\n\n try {\n $sfOpportunities = $syncStrategy->fetchOpportunities($parameters);\n $totalRecords = $sfOpportunities->count();\n\n foreach ($sfOpportunities as $sfOpportunity) {\n $this->importOpportunity($sfOpportunity);\n $syncCount++;\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n $this->logger->warning('[' . $this->getDisplayName() . '] No opportunities found', [\n 'teamId' => $this->team->getUuid(),\n 'name' => $name,\n 'params' => $logParams,\n 'reason' => $noResultsException->getMessage(),\n ]);\n } catch (CrmException $crmException) {\n // Nothing to sync.\n $this->logger->warning('[' . $this->getDisplayName() . '] Opportunity sync failed', [\n 'teamId' => $this->team->getUuid(),\n 'name' => $name,\n 'params' => $logParams,\n 'reason' => $crmException->getMessage(),\n ]);\n }\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::OPPORTUNITY, ['params' => $logParams]);\n\n // debug to see how if count of opportunities reaches 1000\n if ($syncCount >= 1000) {\n $this->logger->info(\n '[' . $this->getDisplayName() . '] Sync Opportunities - count warning',\n [\n 'team_id' => $this->team->getId(),\n 'params' => $logParams,\n 'count' => $syncCount,\n 'strategies_count' => count($strategies),\n 'total_records' => $totalRecords ?? null,\n ]\n );\n }\n\n return $syncCount;\n }\n\n\n /**\n * @inheritdoc\n */\n public function syncOpportunity(string $crmId): ?Opportunity\n {\n $strategy = $this->opportunitySyncStrategyResolver->resolve(\n $this->config,\n OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY\n );\n\n $parameters = [\n 'profile' => $this->profile,\n 'crm_id' => $crmId,\n ];\n\n try {\n $sfOpportunity = $strategy->fetchOpportunities($parameters);\n } catch (HttpNotFoundException $e) {\n $this->logger->info('[' . $this->getDisplayName() . '] Opportunity not found', [\n 'teamId' => $this->team->id_string,\n 'crmId' => $crmId,\n ]);\n\n return null;\n } catch (CrmException $crmException) {\n $this->logger->info('[' . $this->getDisplayName() . '] Opportunity sync failed', [\n 'teamId' => $this->team->id_string,\n 'crmId' => $crmId,\n 'exception' => $crmException->getMessage(),\n ]);\n\n return null;\n }\n\n if ($sfOpportunity instanceof ArrayIterator) {\n return $this->importOpportunity($sfOpportunity->getItems());\n }\n\n return $this->importOpportunity($sfOpportunity);\n }\n\n /**\n * @throws HttpNotFoundException\n */\n private function importOpportunity($crmData): ?Opportunity\n {\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $account = null;\n if (empty($crmData['AccountId']) === false) {\n /** @var ?Account $account */\n $account = $this->config->accounts()\n ->where('crm_provider_id', (string) $crmData['AccountId'])\n ->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmData['AccountId']);\n }\n }\n\n $userId = $profile?->getUserId() ?? $account?->getUserId();\n if ($userId === null) {\n $this->logger->error('[Salesforce] | Skip import, no user_id found', [\n 'id' => $crmData['Id'],\n ]);\n\n return null;\n }\n\n /** @var ?Stage $stage */\n $stage = null;\n if (isset($crmData['StageName'])) {\n $stage = $this->config\n ->stages()\n ->where('name', $crmData['StageName'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->orderBy('is_selectable', 'DESC')\n ->orderBy('id')\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $crmData['StageName']);\n }\n }\n\n $recordType = null;\n if (empty($crmData['RecordTypeId']) === false) {\n /** @var ?RecordType $recordType */\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $crmData['RecordTypeId'])\n ->first();\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $closeDate = null;\n if (empty($crmData['CloseDate']) === false) {\n $closeDate = Carbon::parse($crmData['CloseDate'])->format('Y-m-d');\n }\n\n $valueFieldName = 'Amount';\n if ($this->config->opportunity_value_field_id) {\n $valueFieldName = $this->config->opportunityValueField->crm_provider_id;\n }\n\n $data = [\n 'team_id' => $this->team->id,\n 'account_id' => $account->id ?? null,\n 'user_id' => $userId,\n 'owner_id' => $crmData['OwnerId'] ?? null,\n 'name' => mb_strimwidth($crmData['Name'] ?? '', 0, 128),\n 'value' => $crmData[$valueFieldName],\n 'currency_code' => CurrencyFormatter::formatCode($crmData['CurrencyIsoCode'] ?? null),\n 'close_date' => $closeDate,\n 'is_closed' => $crmData['IsClosed'],\n 'is_won' => $crmData['IsWon'],\n 'stage_id' => $stage?->id ?? null,\n 'record_type_id' => $recordType->id ?? null,\n 'remotely_created_at' => $createdDate,\n 'probability' => $crmData['Probability'] ?? null,\n 'forecast_category' => $crmData['ForecastCategoryName'] ?? null,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->opportunities(), $crmData['Id']);\n\n // Do not allow locked DB tables & other errors\n // to interrupt the process of reverting the trashed opportunities\n try {\n /** @var Opportunity $opportunity */\n $opportunity = $this->config->opportunities()\n ->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n // import external fields into crm_field_data if present\n $crmFields = $this->getOpportunitySyncableFields();\n\n $this->importOpportunityCrmFieldData($crmData, $crmFields, $opportunity->id);\n\n $this->handleObjectDeletion($opportunity, $crmData);\n\n if ($opportunity->wasRecentlyCreated) {\n MatchActivitiesToNewOpportunity::dispatch($opportunity->getId());\n }\n\n return $opportunity;\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n $this->logger->error('[Salesforce] importOpportunity failure.', [\n 'crm_provider_id' => $crmData['Id'],\n 'team_id' => $this->team->id,\n 'exception' => $exception->getMessage(),\n ]);\n\n $this->handleEntityDeletionByProviderId($this->config->opportunities(), $crmData);\n }\n\n return null;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContacts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n $fields = $this->getAllFieldsAsArray('contact');\n if (\\in_array('Id', $fields, true) === false) {\n return $syncCount;\n }\n\n $query = '\n SELECT ' . rtrim(implode(',', $fields), ',') . '\n FROM Contact\n WHERE LastModifiedDate > :since\n ORDER BY LastModifiedDate ASC';\n\n try {\n $sfContacts = $this->queryHandler->query($query, [\n 'since' => $since->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($sfContacts as $sfContact) {\n // Only sync if previously imported.\n if ($this->hasContact($sfContact['Id'])) {\n $this->importContact($sfContact);\n $syncCount++;\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::CONTACT);\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContact(string $crmId): ?Contact\n {\n $fields = $this->getAllFieldsAsArray('contact');\n if (! in_array('Id', $fields, true)) {\n $this->logger->info('[Salesforce] Sync contact cancelled. Fields are not available.', [\n 'crmId' => $crmId,\n 'userId' => $this->profile->getUserId(),\n ]);\n\n return null;\n }\n\n $sfContact = $this->getRecord('Contact', $crmId, $fields);\n\n return $this->importContact($sfContact);\n }\n\n private function importContact($crmData): Contact\n {\n $account = null;\n // Contacts may not have accounts...\n if (isset($crmData['AccountId'])) {\n $account = $this->config->accounts()\n ->where('crm_provider_id', (string) $crmData['AccountId'])\n ->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmData['AccountId']);\n }\n }\n\n $countryCode = $crmData['MailingCountryCode'] ?? null;\n\n // Salesforce allows custom \"countries\" to be created. Disregard these.\n if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {\n $countryCode = null;\n }\n\n // If we have no country code, try to parse it from the country name.\n if ($countryCode === null && empty($crmData['MailingCountry']) === false) {\n $countryCode = $this->convertCountryNameToCode($crmData['MailingCountry']);\n\n if ($countryCode === null && $account) {\n $countryCode = $account->country_code;\n }\n }\n\n $ext = null;\n $parsedNumber = [];\n if (empty($crmData['Phone']) === false) {\n $number = Str::limit($crmData['Phone'], 25, '');\n $parsedNumber = parsePhoneNumber($countryCode, $number);\n\n if (empty($parsedNumber['ext']) === false) {\n $ext = Str::limit($parsedNumber['ext'], 10, '');\n }\n }\n\n $mobileNumber = null;\n if (empty($crmData['MobilePhone']) === false) {\n $mobileNumber = Str::limit(phone_e164($countryCode, $crmData['MobilePhone']), 25, '');\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $data = [\n 'team_id' => $this->team->id,\n 'account_id' => $account->id ?? null,\n 'user_id' => $profile?->user_id,\n 'owner_id' => $crmData['OwnerId'] ?? null,\n 'name' => ($crmData['Name'] ?? null) !== null ? mb_strimwidth($crmData['Name'], 0, 100) : '',\n 'title' => ($crmData['Title'] ?? null) !== null ? mb_strimwidth($crmData['Title'], 0, 128) : null,\n 'email' => ($crmData['Email'] ?? null) !== null ? mb_strimwidth($crmData['Email'], 0, 191) : null,\n 'country_code' => $countryCode,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'ext' => $ext,\n 'mobile_phone' => $mobileNumber,\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['Id'],\n modelType: Contact::class,\n fileName: $crmData['Id'],\n avatarText: $crmData['Name']\n ),\n 'remotely_created_at' => $createdDate,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->contacts(), $crmData['Id']);\n\n /** @var Contact $contact */\n $contact = $this->config->contacts()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n $this->handleObjectDeletion($contact, $crmData);\n\n return $contact;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n $fields = [\n 'InstanceName',\n 'OrganizationType',\n 'IsSandbox',\n ];\n\n $orgValues = $this->getRecord('Organization', $this->config->crm_provider_id, $fields);\n\n $edition = null;\n switch ($orgValues['OrganizationType']) {\n case 'Developer Edition':\n $edition = Configuration::EDITION_DEVELOPER;\n\n break;\n\n case 'Professional Edition':\n $edition = Configuration::EDITION_PROFESSIONAL;\n\n break;\n\n case 'Enterprise Edition':\n $edition = Configuration::EDITION_ENTERPRISE;\n\n break;\n }\n\n $this->config->edition = $edition;\n $this->config->instance = $orgValues['InstanceName'];\n\n // XXX: How can this state be possible?\n if ($this->config->version === null) {\n $this->config->version = Client::MIN_API_VERSION;\n }\n\n $installedVersion = $this->getInstalledAppVersion();\n if ($installedVersion !== null) {\n $installedVersion = (string) $this->getInstalledAppVersion();\n }\n\n $this->config->installed_app_version = $installedVersion;\n\n $this->config->save();\n }\n\n public function getInstalledAppVersion(): ?string\n {\n try {\n $query = '\n SELECT\n SubscriberPackageVersion.MajorVersion,\n SubscriberPackageVersion.MinorVersion,\n SubscriberPackageVersion.PatchVersion,\n SubscriberPackageVersion.BuildNumber\n FROM\n InstalledSubscriberPackage\n WHERE\n SubscriberPackageId = :packageId\n ';\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'packageId' => self::INSTALLED_PACKAGE_ID,\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n // Grab version number.\n $version = $sfField['SubscriberPackageVersion']['MajorVersion'] .\n $sfField['SubscriberPackageVersion']['MinorVersion'] .\n $sfField['SubscriberPackageVersion']['PatchVersion'] .\n $sfField['SubscriberPackageVersion']['BuildNumber'];\n } catch (\\Exception) {\n $version = null;\n }\n\n return $version;\n }\n\n /**\n * Store transcripts as note.\n *\n * @throws \\Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // For SF we also check if Log Notes is enabled.\n if ($this->profile->log_notes === Profile::LOG_NOTE_NONE) {\n return;\n }\n\n if ($activity->opportunity_id && $activity->prospect === null) {\n return;\n }\n\n try {\n $transcriptionData = $this->generateTranscription($activity);\n\n $noteMaxLength = $this->profile->log_notes === Profile::LOG_NOTE_ENHANCED\n ? self::ENHANCED_NOTE_MAX_LENGTH\n : self::CLASSIC_NOTE_MAX_LENGTH;\n\n $title = 'Transcript for ';\n $title .= $activity->title ?? $activity->activity_title;\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $body = mb_strimwidth($transcriptionData, 0, $noteMaxLength);\n\n if ($activity->opportunity_id) {\n $objectId = $activity->opportunity->crm_provider_id;\n } else {\n $objectId = $activity->prospect->crm_provider_id;\n }\n\n $noteId = $this->saveNote($title, $body, $objectId);\n\n // Store crm logged id in transcription.\n $transcription = $activity->getTranscription();\n $transcription->crm_activity_id = $noteId;\n $transcription->save();\n } catch (\\Exception $e) {\n \\Sentry::captureException($e);\n }\n }\n\n public function saveNote(string $title, string $body, string $objectId, ?NoteObject $noteObject = null): ?string\n {\n $noteId = null;\n\n try {\n if ($this->profile->log_notes === Profile::LOG_NOTE_ENHANCED) {\n $noteId = $this->buildEnhancedNote($title, $body, $objectId);\n } else {\n $noteId = $this->buildClassicNote($title, $body, $objectId);\n }\n } catch (HttpNotFoundException $exception) {\n // The profile not having access to create Enhanced Notes. Set their preference to Classic.\n if ($this->profile->log_notes === Profile::LOG_NOTE_ENHANCED) {\n $this->profile->update([\n 'log_notes' => Profile::LOG_NOTE_CLASSIC,\n ]);\n }\n }\n\n return $noteId;\n }\n\n /**\n * This is using the \"Enhanced\" Notes feature, NOT the \"Notes & Attachments\" feature being deprecated.\n *\n * @url https://salesforce.stackexchange.com/questions/104408/how-can-i-create-an-account-note-or-contact-note-via-api-that-is-visible-in-sale\n */\n private function buildEnhancedNote(string $title, string $body, string $objectId): string\n {\n // Decode stored entities, escape HTML (without quoting), then convert line breaks for Salesforce formatting\n $decodedBody = html_entity_decode($body, ENT_QUOTES | ENT_HTML5);\n $sanitizedBody = htmlspecialchars($decodedBody, ENT_NOQUOTES, 'UTF-8', false);\n $content = nl2br($sanitizedBody, false);\n $note = [\n 'OwnerId' => $this->profile->crm_provider_id,\n 'Title' => $title,\n 'Content' => base64_encode($content),\n ];\n\n $noteId = $this->createRecord('ContentNote', $note);\n\n $link = [\n 'ContentDocumentId' => $noteId,\n 'LinkedEntityId' => $objectId,\n 'ShareType' => 'I',\n ];\n\n $this->createRecord('ContentDocumentLink', $link);\n\n return $noteId;\n }\n\n private function buildClassicNote(string $title, string $body, string $objectId): string\n {\n if (in_array($this->parseObjectType($objectId), [Field::OBJECT_TASK, Field::OBJECT_EVENT])) {\n $this->logger->info('[Salesforce] Summary not sent', [\n 'profile_id' => $this->profile->id,\n 'objectId' => $objectId,\n 'reason' => 'Classical Note does not support Task/Event relation',\n ]);\n\n return '';\n }\n\n $titleTrimmed = null;\n\n if (mb_strlen($title) > 80) {\n $titleTrimmed = substr($title, 0, 77) . '...';\n }\n $payload = [\n 'OwnerId' => $this->profile->crm_provider_id,\n 'IsPrivate' => false,\n 'Title' => $titleTrimmed ?? $title,\n 'Body' => $titleTrimmed ? $title . PHP_EOL . $body : $body,\n 'ParentId' => $objectId,\n ];\n\n return $this->createRecord('Note', $payload);\n }\n\n /**\n * @inheritdoc\n */\n public function find(string $name, array $scopes): array\n {\n if ($this->profile === null) {\n return [];\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $limitValues = ['limit' => $this->limit, 'offset' => $this->offset];\n $sosl = $queryBuilder->buildFindQuery($name, $scopes, $limitValues);\n\n $this->logger->info('[Salesforce] Find prospects', [\n 'profile_id' => $this->profile->id,\n 'sosl_query' => $sosl,\n 'search_string' => $name,\n 'scopes' => $scopes,\n ]);\n\n $data = Cache::remember($this->profile->id . $sosl, self::CACHE_TTL, function () use ($sosl) {\n $data = [];\n\n try {\n // Hit remote API.\n $objects = $this->queryHandler->search($sosl);\n\n // Build mapped list.\n foreach ($objects as $object) {\n $type = strtolower($object['attributes']['type']);\n\n $record = [\n 'crmId' => $object['Id'],\n 'name' => $object['Name'],\n 'prospectType' => $type,\n 'phoneNumbers' => [],\n 'crmUrl' => $this->generateProviderUrl($object['Id'], $type),\n ];\n\n switch ($type) {\n case 'lead':\n if (empty($object['Company']) === false) {\n $record['organization'] = $object['Company'];\n }\n\n if (empty($object['Title']) === false) {\n $record['title'] = $object['Title'];\n }\n\n $stage = $this->config->stages()\n ->where('type', Stage::TYPE_LEAD)\n ->where('name', $object['Status'])\n ->first();\n\n // Lazy create the stage.\n if ($stage === null) {\n $stage = $this->importStages([Stage::TYPE_LEAD], $object['Status']);\n }\n\n if ($stage) {\n $record += [\n 'stage' => [\n 'id' => $stage->id_string,\n 'name' => $stage->name,\n ],\n ];\n }\n\n if (empty($object['RecordTypeId']) === false) {\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $object['RecordTypeId'])\n ->first();\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n }\n\n break;\n\n case 'account':\n if (empty($object['Industry']) === false) {\n $record['industry'] = $object['Industry'];\n $record['detailsLine'] = $object['Industry'];\n }\n if (! empty($object['PersonEmail'])) {\n $record['detailsLine'] = $object['PersonEmail'];\n }\n\n break;\n\n case 'contact':\n // For contacts, we should try and fetch their account name too.\n if ($object['AccountId']) {\n // Cheaper to get this locally.\n $account = $this->config->accounts()\n ->where('crm_provider_id', $object['AccountId'])\n ->first(['name']);\n\n if ($account) {\n $record['organization'] = $account->name;\n }\n }\n\n if (! empty($object['IsPersonAccount']) && $object['Email']) {\n $record['detailsLine'] = $object['Email'];\n } else {\n if (empty($object['Title']) === false) {\n $record['title'] = $object['Title'];\n }\n }\n\n break;\n }\n\n // Add phone numbers to record.\n if (empty($object['Phone']) === false && $object['Phone']) {\n $record['phoneNumbers'][] = [\n 'number' => $object['Phone'],\n 'nationalFormat' => phone_national($this->profile->user->country_code, $object['Phone']),\n 'type' => 'phone',\n ];\n }\n\n if (empty($object['MobilePhone']) === false && $object['MobilePhone']) {\n $record['phoneNumbers'][] = [\n 'number' => $object['MobilePhone'],\n 'nationalFormat' => phone_national(\n $this->profile->user->country_code,\n $object['MobilePhone']\n ),\n 'type' => 'mobile',\n ];\n }\n\n $data[] = $record;\n }\n } catch (NoResultsException $e) {\n $data = [];\n }\n\n return $data;\n });\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n\n if ($crmAccountId === null) {\n return $data;\n }\n\n if ($userId) {\n $profileRepository = app(ProfileRepository::class);\n $profile = $profileRepository->findProfileByUserId($this->config, $userId);\n\n $ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;\n }\n\n try {\n // Perhaps their profile has no opportunity permissions.\n if ($this->profile === null || $this->profile->opportunity_fields === null) {\n return $data;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $query = $queryBuilder->buildFindOpportunitiesQuery();\n\n $objects = $this->queryHandler->query($query, ['accountId' => $crmAccountId]);\n\n foreach ($objects as $object) {\n $record = [\n 'crmId' => $object['Id'],\n 'name' => $object['Name'],\n 'won' => $object['IsWon'],\n 'closed' => $object['IsClosed'],\n ];\n\n $valueFieldName = 'Amount';\n if ($this->config->opportunity_value_field_id) {\n $valueFieldName = $this->config->opportunityValueField->crm_provider_id;\n }\n\n if (empty($object[$valueFieldName]) === false) {\n $currency = $object['CurrencyIsoCode'] ?? $this->config->default_currency;\n $value = formatCurrency($object[$valueFieldName], $currency);\n\n $record += [\n 'value' => $value,\n ];\n }\n\n $stage = $this->config->stages()\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->where('name', $object['StageName'])\n ->first();\n\n // Lazy create the stage.\n if ($stage === null) {\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $object['StageName']);\n }\n\n $record += [\n 'stage' => [\n 'id' => $stage->id_string,\n 'name' => $stage->name,\n ],\n ];\n\n if (empty($object['RecordTypeId']) === false) {\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $object['RecordTypeId'])\n ->first();\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n }\n\n if ($ownerId && isset($object['OwnerId']) && $object['OwnerId'] === $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n } catch (NoResultsException $e) {\n return $data;\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n public function getContactRolesFromCrm(?Carbon $since = null): array\n {\n $roles = [];\n\n if ($this->profile === null) {\n return $roles;\n }\n\n $queryBuilder = app(QueryBuilder::class, ['profile' => $this->profile]);\n\n $query = $queryBuilder->buildGetContactRolesQuery($since);\n\n try {\n $objects = $this->queryHandler->query($query);\n\n foreach ($objects as $object) {\n $roles[] = [\n 'id' => $object['Id'],\n 'contactId' => $object['ContactId'],\n 'opportunityId' => $object['OpportunityId'],\n 'ownerId' => $object['Opportunity']['OwnerId'] ?? null,\n 'isPrimary' => $object['IsPrimary'],\n 'role' => $object['Role'],\n ];\n }\n } catch (NoResultsException) {\n // Just return an empty array.\n $this->logger->info('[Salesforce] No contact roles found', [\n 'since' => $since?->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n }\n\n return $roles;\n }\n\n public function syncContactRoles(Carbon $since): int\n {\n $contactRoleRepository = app(ContactRoleRepository::class);\n\n $crmContactRoles = $this->getContactRolesFromCrm(since: $since);\n $syncCount = 0;\n $contactRoles = [];\n\n foreach ($crmContactRoles as $crmContactRole) {\n $contactRoles[] = $this->importContactRole($crmContactRole);\n $syncCount++;\n }\n\n $contactRoleRepository->saveContactRoles($contactRoles);\n\n $this->syncRemotelyDeletedContactRoles();\n\n return $syncCount;\n }\n\n private function importContactRole(array $contactRole): array\n {\n $contact = $this->config->contacts()\n ->where('crm_provider_id', $contactRole['contactId'])\n ->first();\n\n if ($contact === null) {\n $contact = $this->syncContact($contactRole['contactId']);\n }\n\n $opportunity = $this->config->opportunities()\n ->where('crm_provider_id', $contactRole['opportunityId'])\n ->first();\n\n if ($opportunity === null) {\n $opportunity = $this->syncOpportunity($contactRole['opportunityId']);\n }\n\n $role = null;\n if (! empty($contactRole['role'])) {\n $role = mb_strimwidth($contactRole['role'], 0, 191);\n }\n\n return [\n 'crm_configuration_id' => $this->config->getId(),\n 'contact_id' => $contact->getId(),\n 'crm_provider_id' => $contactRole['id'],\n 'subject_type' => ContactRole::SUBJECT_TYPE_OPPORTUNITY,\n 'subject_id' => $opportunity->getId(),\n 'is_primary' => $contactRole['isPrimary'],\n 'role' => $role,\n ];\n }\n\n protected function syncRemotelyDeletedContactRoles(): bool\n {\n try {\n $deletedRemotely = $this->queryHandler->queryDeleted('OpportunityContactRole');\n } catch (NoResultsException $e) {\n return false;\n }\n\n $deletedOpportunities = $deletedRemotely->getResults();\n $deletedIds = array_column($deletedOpportunities, 'id');\n\n $contactRoleRepository = app(ContactRoleRepository::class);\n\n foreach (array_chunk($deletedIds, self::HARD_DELETE_CHUNK) as $chunk) {\n $contactRoleRepository->deleteContactRoles($chunk);\n\n $this->logger->info('[' . $this->getDisplayName() . '] Remotely deleted opportunities synced', [\n 'teamId' => $this->team->id_string,\n 'remotelyDeletedOpportunities' => $chunk,\n 'count' => count($chunk),\n ]);\n }\n\n return true;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = $objects = [];\n\n $hasWho = \\in_array($objectType, ['lead', 'contact']);\n $playbook = $this->getPlaybook($this->profile->user);\n $fields = array_merge(\n $this->profile->getFieldsAsArray(parent::OBJECT_TASK),\n $playbook && $playbook->activityField ? [$playbook->activityField->crm_provider_id] : []\n );\n\n // Query should default to any open call for that user.\n $query = '\n SELECT ' . implode(',', array_unique($fields)) . '\n FROM Task\n WHERE OwnerId = :ownerId\n AND IsArchived = false\n AND IsDeleted = false\n AND IsClosed = false\n AND (';\n\n if ($objectType === 'account') {\n // This covers tasks tied to a related contact or opportunity too.\n $query .= '\n AccountId = :accountId';\n }\n\n if ($hasWho) {\n $query .= '\n WhoId = :whoId';\n\n // If we are also going to check on a specific opportunity, set that up.\n if ($opportunityId) {\n $query .= ' OR WhatId = :whatId';\n }\n }\n\n $query .= ' ) ORDER BY LastModifiedDate DESC';\n\n try {\n $objects = $this->queryHandler->query($query, [\n 'ownerId' => $this->profile->crm_provider_id,\n 'whoId' => $objectId,\n 'whatId' => $opportunityId,\n 'accountId' => $objectId,\n ]);\n } catch (NoResultsException $e) {\n return $data;\n } finally {\n $this->logger->debug(sprintf('[Salesforce] Found %s tasks for query \"%s\"', count($objects), $query));\n }\n\n foreach ($objects as $object) {\n $dueDate = $object['ActivityDate'] ? Carbon::parse($object['ActivityDate'])->toIso8601String() : null;\n $data[] = [\n 'crmId' => $object['Id'],\n 'subject' => $object['Subject'],\n 'due' => $dueDate,\n 'type' => $object[$playbook->activityField->crm_provider_id],\n ];\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getEvents(string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = $objects = [];\n $user = $this->profile?->user;\n if ($this->profile === null || $user === null) {\n return $data;\n }\n\n $hasWho = \\in_array($objectType, ['lead', 'contact']);\n $playbook = $this->getPlaybook($user);\n $fields = array_merge(\n $this->profile->getFieldsAsArray(parent::OBJECT_EVENT),\n $playbook && $playbook->activityField ? [$playbook->activityField->crm_provider_id] : []\n );\n\n // Query should default to any event starting in the last week and ending up until today owned by the user.\n $query = '\n SELECT ' . implode(',', array_unique($fields)) . '\n FROM Event\n WHERE OwnerId = :ownerId\n AND IsArchived = false\n AND IsAllDayEvent = false\n AND StartDateTime >= LAST_N_DAYS:7\n AND EndDateTime <= TODAY\n AND (';\n\n if ($objectType === 'account') {\n // This covers events tied to a related contact or opportunity too.\n $query .= '\n AccountId = :accountId';\n }\n\n if ($hasWho) {\n $query .= '\n WhoId = :whoId';\n\n // If we are also going to check on a specific opportunity, set that up.\n if ($opportunityId) {\n $query .= ' OR WhatId = :whatId';\n }\n }\n\n $query .= ' ) ORDER BY LastModifiedDate DESC';\n\n try {\n $objects = $this->queryHandler->query($query, [\n 'ownerId' => $this->profile->crm_provider_id,\n 'whoId' => $objectId,\n 'whatId' => $opportunityId,\n 'accountId' => $objectId,\n ]);\n } catch (NoResultsException $e) {\n return $data;\n } finally {\n $this->logger->debug(sprintf('[Salesforce] Found %s tasks for query \"%s\"', count($objects), $query));\n }\n\n foreach ($objects as $object) {\n $dueDate = $object['StartDateTime'] ? Carbon::parse($object['StartDateTime'])->toIso8601String() : null;\n\n $data[] = [\n 'crmId' => $object['Id'],\n 'subject' => $object['Subject'],\n 'due' => $dueDate,\n 'type' => $object[$playbook->activityField->crm_provider_id],\n ];\n }\n\n return $data;\n }\n\n /**\n * Try to find CRM Objects using email address\n *\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchExactlyByEmail(string $email, ?int $userId = null): ?array\n {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $sosl = $queryBuilder->buildMatchByQuery($email, Field::TYPE_EMAIL);\n if ($sosl === null) {\n return null;\n }\n\n try {\n $objects = $this->queryHandler->search($sosl);\n $objects = $this->queryHandler->prioritiseResults(\n $objects,\n $email,\n QueryHandler::PRIORITISE_EMAIL\n );\n\n $data = $this->convertCrmData($objects, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (NoResultsException $e) {\n // Try the account next.\n if ($this->profile->account_fields === null) {\n return null;\n }\n }\n\n return null;\n }\n\n public function getDomain(string $email): ?string\n {\n // SF improved search - strip the domain extension, min domain name length 4\n return $this->getCompanyNameFromEmail(email: $email, minNameLength: 4);\n }\n\n /**\n * Try to find CRM objects using domain name of the email address\n *\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByDomain(string $domain, ?int $userId = null): ?array\n {\n $companyName = $domain;\n\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $sosl = $queryBuilder->buildMatchByDomainQuery($companyName);\n\n try {\n $objects = $this->queryHandler->search($sosl);\n\n $data = $this->convertCrmData($objects, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (NoResultsException) {\n return null;\n }\n }\n\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n // Don't bother looking up numbers that are masked.\n if (str_contains($phone, '**')) {\n return null;\n }\n\n if ($this->isPhoneNumberOfTeamMember($phone)) {\n return null;\n }\n\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $phoneNational = phone_national(null, $phone) ?? '';\n $possiblePhoneFormats = collect([\n preg_replace('/\\D/', '', ltrim($phone, '0+')),\n preg_replace('/\\D/', '', $phoneNational),\n formatDashPhoneNumber($phone),\n $phoneNational,\n ])\n ->filter() // Removes null and empty strings\n ->unique()\n ->values();\n\n foreach ($possiblePhoneFormats as $phone) {\n $sosl = $queryBuilder->buildMatchByQuery($phone, Field::TYPE_PHONE);\n if ($sosl === null) {\n continue;\n }\n\n try {\n $objects = $this->queryHandler->search($sosl);\n $objects = $this->queryHandler->prioritiseResults(\n $objects,\n $phone,\n QueryHandler::PRIORITISE_PHONE\n );\n\n return $this->convertCrmData($objects, $userId);\n } catch (NoResultsException) {\n continue;\n }\n }\n\n return null;\n }\n\n private function isPhoneNumberOfTeamMember(string $phone): bool\n {\n $teamRepository = app(TeamRepository::class);\n $user = $teamRepository->findTeamMemberByPhone($this->team, $phone);\n\n if ($user instanceof User) {\n return true;\n }\n\n return false;\n }\n\n protected function getCacheKey(string $object, ?int $userId = null): ?string\n {\n $key = $this->profile->id . $object;\n $keySuffix = $this->getOwnerKeySuffix($userId);\n\n return $key . $keySuffix;\n }\n\n private function getOwnerKeySuffix(?int $userId = null): string\n {\n return $userId === null ? '' : (string) $userId;\n }\n\n /** Determine the CRM Objects which represent the call activity. */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n if ($this->profile === null) {\n return null;\n }\n\n $cacheKey = $this->getCacheKey($name, $userId);\n\n $result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $sosl = $queryBuilder->buildMatchByQuery($name, 'name');\n if ($sosl === null) {\n return false;\n }\n\n try {\n $objects = $this->queryHandler->search($sosl);\n } catch (NoResultsException $e) {\n return false;\n }\n\n $objects = $this->queryHandler->prioritiseResults(\n $objects,\n $name,\n QueryHandler::PRIORITISE_NAME\n );\n\n $data = $this->convertCrmData($objects, $userId);\n\n return (! empty(array_filter($data))) ? $data : false;\n });\n\n return is_array($result) ? $result : null;\n }\n\n /**\n * @return array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n protected function convertCrmData(QueryIterator $objects, ?int $userId = null): array\n {\n $lead = null;\n $contact = null;\n $opportunity = null;\n $account = null;\n $stage = null;\n $countryCode = null;\n\n if ($objects->count() > 0) {\n $object = $objects->current();\n\n if ($object['attributes']['type'] === 'Lead') {\n $lead = $this->importLead($object);\n\n // Lead might not be imported if the Stage is null for example.\n if ($lead) {\n $countryCode = $lead->country_code;\n $stage = $lead->stage;\n }\n } else {\n if ($object['attributes']['type'] === 'Contact') {\n $contact = $this->importContact($object);\n $account = $contact->account;\n } else {\n $account = $this->importAccount($object);\n }\n\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account) {\n $countryCode = $account->country_code;\n }\n\n try {\n $sfOpportunities = $this->findOpportunities(\n $account?->getCrmProviderId(),\n $contact?->getCrmProviderId(),\n $userId\n );\n\n // Take the first opportunity, which will be ordered as priority based on their settings.\n if (! empty($sfOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($sfOpportunities[0]['crmId']);\n $stage = $opportunity?->stage;\n }\n } catch (Exception) {\n // Nothing to see here.\n }\n }\n }\n\n return [\n $lead,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n /**\n * @inheritdoc\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n if ($stage->type === Stage::TYPE_LEAD) {\n $objectType = 'Lead';\n $objectId = $crmObject->crm_provider_id;\n $objectStageType = 'Status';\n } else {\n $objectType = 'Opportunity';\n $objectId = $crmObject->crm_provider_id;\n $objectStageType = 'StageName';\n }\n\n $headers = [];\n if ($this->config->trigger_assignment_rules === false) {\n // @see: https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/headers_autoassign.htm\n $headers = [\n 'Sforce-Auto-Assign' => 'false',\n ];\n }\n\n $this->updateRecord($objectType, $objectId, [$objectStageType => $stage->name], $headers);\n }\n\n public function parseObjectType(string $objectId): string\n {\n if (Str::startsWith($objectId, '001')) {\n return 'account';\n }\n\n if (Str::startsWith($objectId, '003')) {\n return 'contact';\n }\n\n if (Str::startsWith($objectId, '00Q')) {\n return 'lead';\n }\n\n if (Str::startsWith($objectId, '006')) {\n return 'opportunity';\n }\n\n if (Str::startsWith($objectId, '00U')) {\n return 'event';\n }\n\n if (Str::startsWith($objectId, '00T')) {\n return 'task';\n }\n\n throw new \\InvalidArgumentException('Unsupported Object Type');\n }\n\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, ['profile' => $this->profile]);\n $query = $queryBuilder->buildGetUsersQuery($userToSearch);\n\n try {\n $salesforceUsers = $this->queryHandler->query($query, [\n 'active' => true,\n ]);\n } catch (NoResultsException $e) {\n $this->logger->info('[Salesforce] Sync Profiles. No users found', [\n 'query' => $query,\n 'error' => $e->getMessage(),\n ]);\n\n return null;\n }\n\n $teamRepository = app(TeamRepository::class);\n $customRules = $this->getCustomProfileRules($teamRepository);\n\n foreach ($salesforceUsers as $crmUser) {\n if ($crmUser['Email'] === null) {\n continue;\n }\n\n if (! $this->customProfileValidation($crmUser, $customRules)) {\n continue;\n }\n\n $user = $teamRepository->findActiveTeamMemberByEmail($this->team, $crmUser['Email']);\n\n if (! $user instanceof User) {\n continue;\n }\n\n $edition = $crmUser['UserPreferencesLightningExperiencePreferred']\n ? Profile::EDITION_LIGHTNING\n : Profile::EDITION_CLASSIC;\n\n $profileRepository = app(ProfileRepository::class);\n $profile = $profileRepository->updateOrCreateProfile(\n $user,\n [\n 'crm_configuration_id' => $this->config->getId(),\n 'crm_provider_id' => $crmUser['Id'],\n ],\n [\n 'user_id' => $user->getId(),\n 'edition' => $edition,\n 'has_external_cti' => ! empty($crmUser['CallCenterId']),\n 'crm_profile_id' => $crmUser['ProfileId'],\n ]\n );\n\n if ($userToSearch instanceof User && $userToSearch->getId() === $user->getId()) {\n return $profile;\n }\n }\n\n // Clean up inactive profiles\n try {\n $this->archiveInactiveProfiles();\n } catch (\\Exception $e) {\n $this->logger->warning('[Salesforce] Profile archiving failed', [\n 'teamId' => $this->team->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $url = null;\n\n // For Salesforce it's easy, we just point every object to the apex domain and they handle it.\n switch ($objectType) {\n case 'lead':\n case 'account':\n case 'contact':\n case 'opportunity':\n case 'task':\n case 'event':\n case 'activity':\n\n $url = $this->config->crm_base_url . '/' . $providerId;\n\n break;\n }\n\n return $url;\n }\n\n public function buildTaskSearchFields(): array\n {\n return ['Id', 'WhoId', 'WhatId', 'AccountId'];\n }\n\n public function getTaskByFilterConditions(\n array $fields,\n array $filters,\n bool $bulkSearch = false,\n bool $strictFilters = true\n ): ?array {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $query = $queryBuilder->buildSearchTaskQuery($fields, $filters, $bulkSearch, $strictFilters);\n\n try {\n if (! $bulkSearch) {\n $objects = $this->queryHandler->query($query, $filters);\n if ($objects->count() === 1) {\n return $objects->current();\n }\n }\n\n if ($bulkSearch) {\n $objects = $this->queryHandler->query($query);\n $records = [];\n foreach ($objects as $record) {\n $key = $record[end($fields)];\n $records[$key] = $record;\n }\n\n return $records;\n }\n } catch (\\Exception $e) {\n $this->logger->info('[Salesforce] Failed to execute query', [\n 'query' => $query,\n 'error' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function mapCrmObjects(array $task): array\n {\n $activityData = [];\n\n if (! empty($task['WhoId'])) {\n $type = $this->parseObjectType($task['WhoId']);\n $activityData[$type] = $task['WhoId'];\n }\n if (! empty($task['AccountId'])) {\n $activityData['account'] = $task['AccountId'];\n }\n if (! empty($task['WhatId'])) {\n $activityData['opportunity'] = $task['WhatId'];\n }\n\n return $activityData;\n }\n\n /**\n * Get SF task by Outreach call id.\n */\n public function getTaskByFilter(\n string $activityFieldType,\n array $filters,\n string $operator = '=',\n array $additionalFields = []\n ): ?array {\n $data = [];\n\n try {\n // Default (base) fields.\n $fields = ['Id', 'Subject', 'Description', 'ActivityDate', 'WhoId', 'WhatId', $activityFieldType];\n\n foreach ($additionalFields as $additionalField) {\n $fields[] = $additionalField->crm_provider_id;\n }\n\n $fields = array_unique($fields);\n\n // Find task with the same Outreach id as the call id.\n $query = 'SELECT ' . implode(',', $fields) . '\n FROM Task\n WHERE IsArchived = false AND IsDeleted = false';\n\n foreach ($filters as $key => $value) {\n $key = preg_quote($key, '/');\n $key = str_replace(['\\'', '\"'], '', $key);\n // Prepare the substitution.\n $strKey = \":$key\";\n\n $query .= \" AND $key $operator $strKey\";\n }\n\n $query .= ' ORDER BY LastModifiedDate DESC LIMIT 1';\n\n $objects = $this->queryHandler->query($query, $filters);\n\n // There should be only one task related to this call if any.\n if ($objects->count() === 1) {\n $object = $objects->current();\n\n $dueDate = $object['ActivityDate'] ? Carbon::parse($object['ActivityDate'])->toIso8601String() : null;\n\n $data = array_merge($object, [\n 'crmId' => $object['Id'],\n 'subject' => $object['Subject'],\n 'summary' => $object['Description'],\n 'due' => $dueDate,\n 'Type' => $object[$activityFieldType],\n ]);\n }\n } catch (NoResultsException $e) {\n // Filters don't match any records.\n } catch (ServiceUnavailableException $serviceUnavailableException) {\n // Service cannot be queried. We should probably log this.\n }\n\n return $data;\n }\n\n /**\n * Get Salesforce fields including datetime fields\n *\n * @param $objectType\n */\n private function getAllFieldsAsArray($objectType): array\n {\n $basicFields = [];\n // Not all users have access to all object fields.\n if ($this->profile->{$objectType . '_fields'}) {\n $basicFields = explode(',', $this->profile->{$objectType . '_fields'});\n }\n\n $extraFields = [\n 'CreatedDate',\n 'LastModifiedDate',\n 'IsDeleted',\n ];\n\n if ($objectType === self::OBJECT_OPPORTUNITY\n && $this->config->opportunity_value_field_id\n && ! in_array($this->config->opportunityValueField->crm_provider_id, $basicFields)\n ) {\n $extraFields[] = $this->config->opportunityValueField->crm_provider_id;\n }\n\n return array_unique(array_merge($basicFields, $extraFields));\n }\n\n /**\n * Generate transcription for activity description.\n */\n private function generateTranscription(Activity $activity): string\n {\n if (! ($this->config->store_transcript)) {\n // If sending transcription to activity toggle is disabled\n return '';\n }\n\n return $this->transcriptionService\n ->findTranscriptionByActivity($activity)\n ->map(static function (array $transcriptionSegment): string {\n return $transcriptionSegment['formattedStartsAt'] . ' | ' . $transcriptionSegment['transcript'];\n })\n ->implode(PHP_EOL);\n }\n\n /**\n * Find related Salesforce event based on activity data\n *\n * @return array<string>\n */\n public function fetchRelatedActivity(Activity $activity): array\n {\n $this->logger->info('[Salesforce] Searching for related activity', [\n 'activityId' => $activity->getUuid(),\n 'ownerId' => $this->profile?->crm_provider_id,\n ]);\n\n $sfEvent = $this->fetchRelatedEvent($activity);\n if (empty($sfEvent)) {\n $this->logger->info('[Salesforce] No related activity found', [\n 'activityId' => $activity->getUuid(),\n 'ownerId' => $this->profile?->crm_provider_id,\n 'account' => $activity->hasAccount()\n ? $activity->getAccount()->getCrmProviderId()\n : null,\n ]);\n\n return [];\n }\n\n return $sfEvent;\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n if ($activity->isTypeConference() === false) {\n return null;\n }\n\n if ($activity->hasActualStartTime() === false && $activity->hasScheduledStartTime() === false) {\n return null;\n }\n\n if (! $activity->hasProspect()) {\n $this->logger->info('[Salesforce] Skip look up, Activity not linked to Lead, Contact or Account', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n $playbook = $this->getPlaybook($activity->getUser());\n if ($playbook !== null && $playbook->getActivityType() === Playbook::ACTIVITY_TYPE_TASK) {\n $this->logger->info('[Salesforce] Skip auto-sync for task-based playbook', [\n 'activityUuid' => $activity->getUuid(),\n 'playbookId' => $playbook->getId(),\n 'playbookType' => $playbook->getActivityType(),\n ]);\n\n return null;\n }\n\n try {\n $sfEvent = $this->fetchRelatedActivity($activity);\n if (empty($sfEvent)) {\n return null;\n }\n\n [$activityField, $activityType] = $this->resolveActivityTypeFromEvent($activity, $sfEvent);\n\n $this->logger->info('[Salesforce] Found related activity', [\n 'activityId' => $activity->getUuid(),\n 'sfEvent' => $sfEvent['Id'],\n 'activityFieldName' => $activityField,\n 'crmActivityType' => ($activityField !== null && isset($sfEvent[$activityField]))\n ? $sfEvent[$activityField]\n : null,\n 'activityType' => $activityType,\n ]);\n\n $userId = $this->findRelatedActivityUserId($activity, $sfEvent);\n\n if ($activity->getUserId() !== $userId) {\n $this->logger->info('[Salesforce] Updating meeting owner', [\n 'activityId' => $activity->getUuid(),\n 'oldUserId' => $activity->getUserId(),\n 'newUserId' => $userId,\n ]);\n }\n\n $this->updateSfEventDescription($activity, $sfEvent);\n\n $activity->update([\n 'user_id' => $userId,\n 'crm_provider_id' => $sfEvent['Id'],\n 'playbook_category_id' => $activityType->id ?? $activity->getCategory()?->getId(),\n ]);\n\n $this->logger->info('[Salesforce] Activity updated', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return $activity;\n } catch (\\Exception $exception) {\n \\Sentry::captureException($exception);\n\n throw $exception;\n }\n }\n\n /**\n * @param array<string, mixed> $sfEvent\n *\n * @return array{0: string|null, 1: mixed}\n */\n private function resolveActivityTypeFromEvent(Activity $activity, array $sfEvent): array\n {\n $activityField = $this->getActivityFieldName($activity);\n $activityType = null;\n\n if ($activityField !== null && ! empty($sfEvent[$activityField])) {\n $playbook = $this->getPlaybook($activity->getUser());\n $activityType = $this->getPlaybookCategory($playbook, strval($sfEvent[$activityField]));\n }\n\n return [$activityField, $activityType];\n }\n\n /**\n * @param array<string> $sfEvent\n */\n private function findRelatedActivityUserId(Activity $activity, array $sfEvent): int\n {\n $userId = $activity->getUserId();\n\n if (empty($sfEvent['OwnerId']) === false) {\n $profile = $this\n ->config\n ->profiles()\n ->where('crm_provider_id', $sfEvent['OwnerId'])\n ->get()\n ->filter(static function (Profile $profile) use ($activity): bool {\n if (! $activity->isTypeConference()) {\n return ! empty($profile->user) ? $profile->user->isStatusActive() : false;\n }\n\n $participants = $activity->getParticipants();\n\n return ! empty($profile->user)\n ? $profile->user->isStatusActive()\n && $profile->user->hasPermission(PermissionEnum::RECORD_MEETING)\n && $participants->contains('user_id', $profile->user_id)\n : false;\n })\n ->first();\n\n if ($profile) {\n $userId = $profile->user_id;\n }\n }\n\n return $userId;\n }\n\n /**\n * @param array<string, mixed> $sfEvent\n */\n private function updateSfEventDescription(Activity $activity, array $sfEvent): void\n {\n try {\n if (str_contains($sfEvent['Description'], $activity->id_string)) {\n return;\n }\n\n $payload = [\n 'Description' => $sfEvent['Description']\n . PHP_EOL\n . PHP_EOL\n . (new DecorateActivity())->generateDescription($activity),\n ];\n\n $this->logger->info('[Salesforce] Update record', [\n 'activityId' => $activity->getUuid(),\n 'sfEvent' => $sfEvent['Id'],\n 'payload' => $payload,\n ]);\n\n $payload = array_merge(\n $payload,\n $this->payloadBuilder->fetchCustomFieldData($activity, Field::OBJECT_EVENT)\n );\n\n $this->updateRecord('Event', $sfEvent['Id'], $payload);\n } catch (\\Exception) {\n $this->logger->error('[Salesforce] Failed to update record', [\n 'activityUuid' => $activity->getUuid(),\n 'sfEvent' => $sfEvent['Id'],\n ]);\n }\n }\n\n /**\n * Returns the most recently modified Event within time range (if any).\n *\n * @return array|null An Event record from Salesforce.\n */\n private function fetchRelatedEvent(Activity $activity): ?array\n {\n $ownerId = $this->profile?->crm_provider_id;\n if ($ownerId === null) {\n return [];\n }\n\n /** @var ?Carbon $from */\n /** @var ?Carbon $to */\n [$from, $to] = $this->getFromToDates($activity);\n\n try {\n $whoId = null;\n $hasWho = $activity->lead_id || $activity->contact_id;\n if ($hasWho) {\n $whoId = $activity->hasLead()\n ? $activity->getLead()->crm_provider_id\n : $activity->getContact()->crm_provider_id;\n }\n\n if ($hasWho === false && $activity->account_id === null) {\n return null;\n }\n\n $query = $this->buildFetchRelatedEventQuery($activity);\n\n $objects = $this->queryHandler->query($query, [\n 'ownerId' => $ownerId,\n 'whoId' => $whoId,\n 'whatId' => $activity->hasOpportunity() ? $activity->getOpportunity()->crm_provider_id : null,\n 'accountId' => $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null,\n 'from' => $from?->format('Y-m-d\\TH:i:s\\Z'),\n 'to' => $to?->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($objects as $object) {\n return $object;\n }\n } catch (NoResultsException $e) {\n return [];\n }\n\n return [];\n }\n\n private function getFromToDates(Activity $activity): array\n {\n $from = null;\n $to = null;\n\n /** @var ?CalendarEvent $calendarEvent */\n $calendarEvent = $activity->calendarEvent()->first();\n if ($calendarEvent !== null) {\n $from = $calendarEvent->getStartTime();\n $to = $calendarEvent->getEndTime();\n }\n\n // For non-calendar imported activities\n // Also double check if calendar event dates could be null?\n // If null use what we've got so far\n if ($from === null || $to === null) {\n $from = $activity->hasScheduledStartTime()\n ? $activity->getScheduledStartTime()\n : $activity->getActualStartTime();\n $to = $activity->hasScheduledEndTime()\n ? $activity->getScheduledEndTime()->addMinutes(15)\n : $activity->getActualEndTime();\n }\n\n return [$from, $to];\n }\n\n /**\n * Determines the appropriate activity field name for querying Salesforce events.\n *\n * This method follows a hierarchy to determine the field name:\n * 1. Uses the playbook's activity field if it exists and is in the profile's accessible fields\n * 2. Falls back to the default activity field if the profile has no event fields configured\n * 3. Returns null if no suitable field is found\n *\n * @param Activity $activity The activity to determine the field for\n *\n * @return string|null The field name to use in queries, or null if none is available\n */\n private function getActivityFieldName(Activity $activity): ?string\n {\n if ($this->profile === null) {\n $this->logger->warning('[Salesforce] Cannot determine activity field - profile not found', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n $profileEventFields = $this->profile->getFieldsAsArray('event');\n\n if (empty($profileEventFields)) {\n $defaultActivityField = $this->getDefaultActivityField(Field::OBJECT_EVENT);\n $defaultFieldName = $defaultActivityField?->getAttribute('crm_provider_id');\n // Profile not yet synced — fall back to the default activity field.\n // There is a small chance that the profile won't have Default Activity Type field access\n // in which case the query will fail.\n // This is however an edge case and should be reviewed for profile sync issues.\n Sentry::withScope(function (\\Sentry\\State\\Scope $scope) use ($defaultFieldName): void {\n $scope->setContext('details', [\n 'profileId' => $this->profile->id,\n 'defaultField' => $defaultFieldName,\n ]);\n Sentry::captureMessage(\n '[Salesforce] Profile event fields empty, falling back to default activity field.',\n \\Sentry\\Severity::warning()\n );\n });\n\n return $defaultFieldName;\n }\n\n $playbook = $this->getPlaybook($activity->getUser());\n\n if (! is_null($playbook) && ! is_null($playbook->getActivityField())) {\n $playbookFieldName = $playbook->getActivityField()->getAttribute('crm_provider_id');\n\n if (in_array($playbookFieldName, $profileEventFields, true)) {\n return $playbookFieldName;\n }\n\n $this->logger->warning('[Salesforce] Playbook activity field not found in profile fields', [\n 'activityId' => $activity->getUuid(),\n 'playbookField' => $playbookFieldName,\n 'profileId' => $this->profile->id,\n ]);\n }\n\n return null;\n }\n\n private function buildFetchRelatedEventQuery(Activity $activity): string\n {\n $hasWho = $activity->lead_id || $activity->contact_id;\n\n $activityFieldName = $this->getActivityFieldName($activity);\n $fields = array_filter(['Id', 'Description', 'OwnerId', $activityFieldName]);\n\n $ownerCondition = '(OwnerId = :ownerId OR CreatedById = :ownerId)';\n\n $query = '\n SELECT ' . implode(',', $fields) . '\n FROM Event\n WHERE ' . $ownerCondition . '\n AND IsArchived = false\n AND IsAllDayEvent = false\n AND StartDateTime >= :from\n AND EndDateTime <= :to\n AND (';\n\n $operator = '';\n if ($activity->account_id) {\n // This covers events tied to a related contact or opportunity too.\n $query .= 'AccountId = :accountId';\n\n $operator = ' OR ';\n }\n\n if ($hasWho) {\n $query .= $operator . 'WhoId = :whoId';\n\n // If we are also going to check on a specific opportunity, set that up.\n if ($activity->opportunity_id) {\n $query .= ' OR WhatId = :whatId';\n }\n }\n\n $query .= ') ORDER BY LastModifiedDate DESC';\n\n return $query;\n }\n\n public function fetchProspect(array $task): array\n {\n $lead = $account = $opportunity = $contact = $stage = $countryCode = null;\n $externalId = $task['WhoId'] ?? null;\n\n // Lead or Contact\n if ($externalId) {\n try {\n [$lead, $account, $opportunity, $contact, $stage, $countryCode] = $this->parseRecords($externalId);\n } catch (\\InvalidArgumentException $exception) {\n // Invalid object type.\n }\n }\n\n // If we happen to know the opportunity or account from the Task, figure that out.\n if (empty($task['WhatId']) === false) {\n // WhatId could be either Account ID or Opportunity ID.\n // If WhatId is Opportunity ID, get the opportunity and stage from the CRM.\n try {\n [, $account, $opportunity, , $stage, ] = $this->parseRecords($task['WhatId']);\n } catch (\\InvalidArgumentException $exception) {\n // Invalid object type.\n }\n }\n\n return [$lead, $account, $opportunity, $contact, $stage, $countryCode];\n }\n\n /**\n * Save activity transcription summary as note\n */\n public function saveTranscriptionSummaryAsNote(\n ActivityContract $activity,\n string $title,\n string $body,\n ?string $objectId,\n ?NoteObject $noteObject = null,\n ): ?string {\n return $this->saveNote($title, $body, (string) $objectId);\n }\n\n public function getObjectByFilterConditions(string $objectType, array $fields, array $filters): ?array\n {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $query = $queryBuilder->buildObjectSearchQuery($objectType, $fields, $filters);\n\n try {\n $objects = $this->queryHandler->query($query, $filters);\n if ($objects->count() === 1) {\n return $objects->current();\n }\n } catch (\\Exception $e) {\n $this->logger->info('[Salesforce] Failed to execute query', [\n 'query' => $query,\n 'error' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n private function getCustomProfileRules(TeamRepository $teamRepository): array\n {\n $teamSettings = $teamRepository->getTeamSetting($this->team, 'custom_profile_validation');\n\n if ($teamSettings instanceof TeamSettings && $teamSettings->getValueType() === 'array') {\n $customRules = json_decode($teamSettings->getValue(), true);\n if (is_array($customRules)) {\n return $customRules;\n }\n }\n\n return [];\n }\n\n private function customProfileValidation(array $crmUser, array $customRules): bool\n {\n foreach ($customRules as $customRule) {\n if ($crmUser[$customRule['field']] !== $customRule['value']) {\n return false;\n }\n }\n\n return true;\n }\n\n /**\n * When syncing Contact / Lead / Account / Opportunity / Stage crm entities,\n * validate and restore locally trashed objects,\n * before updating them. Objects are identified by CrmProviderId\n */\n private function restoreAnyTrashedEntity(HasMany $targetEntity, string $crmProviderId): void\n {\n $recordExists = $targetEntity->withTrashed()->where(['crm_provider_id' => $crmProviderId])->first();\n if ($recordExists && $recordExists->trashed()) {\n $recordExists->restore();\n }\n }\n\n #[\\Override] public function supportsNotes(): bool\n {\n return true;\n }\n\n private function getOwnerProfile(?string $ownerId): ?Profile\n {\n if ($ownerId === null) {\n return null;\n }\n\n return $this->config->profiles()\n ->where('crm_provider_id', $ownerId)\n ->first();\n }\n}","depth":2,"bounds":{"left":0.25531915,"top":0.42697525,"width":0.29089096,"height":0.57302475},"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Salesforce;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse Illuminate\\Database\\Eloquent\\Relations\\HasMany;\nuse Illuminate\\Events\\Dispatcher;\nuse Illuminate\\Support\\Facades\\Cache;\nuse Illuminate\\Support\\Facades\\Log;\nuse Illuminate\\Support\\Str;\nuse Jiminny\\Component\\Country\\CountriesMap;\nuse Jiminny\\Contracts\\Acl\\PermissionEnum;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\FetchRelatedActivityInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\ImportsBusinessProcessesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\SalesforceBatchSyncInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\SalesforceInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteNoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SearchTaskInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SendSummaryToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SupportsObjectTypeParseInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmProfileRecordTypesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Enums\\CrmObject;\nuse Jiminny\\Events\\Activities\\Crm\\LeadConverted;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpBadRequestException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Exceptions\\NoResultsException;\nuse Jiminny\\Exceptions\\ServiceUnavailableException;\nuse Jiminny\\Jobs\\Crm\\NoteObject;\nuse Jiminny\\Jobs\\Crm\\MatchActivitiesToNewOpportunity;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Calendar\\CalendarEvent;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Contracts\\ActivityContract;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Configuration;\nuse Jiminny\\Models\\Crm\\ContactRole;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Crm\\RecordType;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\TeamSettings;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\ContactRoleRepository;\nuse Jiminny\\Repositories\\Crm\\FieldRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Repositories\\Crm\\RecordTypeFieldValuesRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\Helpers\\ArrayIterator;\nuse Jiminny\\Services\\Crm\\MatchDomainByEmailInterface;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Services\\Crm\\ResolveCompanyNameByEmailTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\Fields\\FieldHelper;\nuse Jiminny\\Services\\Crm\\Salesforce\\Fields\\FieldTypeConverter;\nuse Jiminny\\Services\\Crm\\Salesforce\\Fields\\ValueNormalizer;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\FollowupActivityTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\LogActivityTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\RecordManipulationsTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\SyncFieldsTrait;\nuse Jiminny\\Utils\\CurrencyFormatter;\nuse Jiminny\\Utils\\StringUtil;\nuse Ramsey\\Uuid\\Uuid;\nuse Sentry\\Laravel\\Facade as Sentry;\n\nclass Service extends BaseService implements\n SalesforceInterface,\n SalesforceBatchSyncInterface,\n SyncCrmEntitiesInterface,\n SyncCrmProfileRecordTypesInterface,\n ImportsBusinessProcessesInterface,\n RemoteEntityManipulationInterface,\n FetchRelatedActivityInterface,\n SendSummaryToCrmInterface,\n MatchDomainByEmailInterface,\n SearchTaskInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n SupportsObjectTypeParseInterface,\n RemoteNoteEntityManipulationInterface,\n VerifyTaskExistsInterface\n{\n use ResolveCompanyNameByEmailTrait;\n use SyncFieldsTrait;\n use DeleteObjectsTrait;\n use RecordManipulationsTrait;\n use ServiceTraits\\BatchSyncTrait;\n use FollowupActivityTrait;\n use LogActivityTrait;\n\n /**\n * Note Body Limit for the Old Note-Taking Tool\n *\n * @var int\n */\n private const int CLASSIC_NOTE_MAX_LENGTH = 32000;\n\n /**\n * Note Content Limit for the New Notes\n *\n * @var int\n */\n private const int ENHANCED_NOTE_MAX_LENGTH = 50000000;\n\n private const string INSTALLED_PACKAGE_ID = '033Tw0000007bKbIAI';\n\n private const int CACHE_TTL = 600;\n\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day - 86400\n\n /**\n * @var Client\n */\n protected $client;\n\n protected PayloadBuilder $payloadBuilder;\n protected QueryHandler $queryHandler;\n\n private OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n\n public function __construct(\n Client $client,\n PayloadBuilder $payloadBuilder,\n protected Dispatcher $eventDispatcher,\n private readonly CountriesMap $countriesMap,\n private readonly ProspectPhotoPathService $prospectPhotoPathService,\n ) {\n parent::__construct();\n\n $this->client = $client;\n $this->payloadBuilder = $payloadBuilder;\n $this->queryHandler = app(QueryHandler::class, [\n 'client' => $this->client,\n 'logger' => $this->logger,\n ]);\n $this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n }\n\n public function getDisplayName(): string\n {\n return 'Salesforce';\n }\n\n public function getJobDelay(): int\n {\n return 1;\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n return $user->getSocialAccount(SocialAccount::PROVIDER_SALESFORCE);\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($activity, $crmProviderId) {\n $playbook = $this->getPlaybookFromActivity($activity);\n\n if ($playbook === null) {\n $this->logger->warning('[Salesforce] Cannot verify task - no playbook found', [\n 'activity' => $activity->getId(),\n 'crm_provider_id' => $crmProviderId,\n ]);\n\n return false;\n }\n\n $objectType = $playbook->getActivityType() === Playbook::ACTIVITY_TYPE_EVENT ? 'Event' : 'Task';\n\n try {\n $record = $this->getRecord($objectType, $crmProviderId, ['Id', 'IsDeleted']);\n\n return ! empty($record) && ($record['IsDeleted'] ?? false) === false;\n } catch (HttpNotFoundException|HttpBadRequestException) {\n $this->logger->info('[Salesforce] Activity record not found during verification', [\n 'activity' => $activity->getId(),\n 'object_type' => $objectType,\n 'crm_provider_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n });\n }\n\n public function query(string $queryToRun, array $parameters = []): QueryIterator\n {\n // Due to poorly designed external calls, this method cannot be entirely removed\n return $this->queryHandler->query($queryToRun, $parameters);\n }\n\n /*=========== Organization Information ===============*/\n\n /**\n * Get a list of all the API Versions for the instance.\n *\n * @throws CrmException\n *\n * @return mixed\n *\n */\n public function getApiVersions()\n {\n $url = $this->config->crm_base_url . '/services/data';\n\n $response = $this->client->get($url);\n\n return json_decode($response->getBody(), true);\n }\n\n /**\n * Gets the valid recordTypes for a given Salesforce Object via the describe API.\n */\n private function getRecordTypes(string $crmObject): array\n {\n $url = $this->client->getObjectsUrl() . $crmObject . '/describe';\n\n $response = $this->client->get($url);\n $jsonResponse = json_decode($response->getBody(), true);\n\n $fields = [];\n foreach ($jsonResponse['recordTypeInfos'] as $row) {\n $fields[] = ['recordTypeId' => $row['recordTypeId'], 'default' => $row['defaultRecordTypeMapping']];\n }\n\n return $fields;\n }\n\n /**\n * Convert raw field data into a format compatible with CRM APIs.\n */\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return ValueNormalizer::normalize($fieldType, $fieldValue, $internal);\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n $defaultFields = ($activityType === Playbook::ACTIVITY_TYPE_TASK)\n ? FieldDefinitions::defaultTaskFields()\n : FieldDefinitions::defaultEventFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n // Setup the activity field as the default Type.\n /** @var Field $activityField */\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'Type',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK, Playbook::ACTIVITY_TYPE_EVENT];\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n\n $fieldFilter = ($activityType === Playbook::ACTIVITY_TYPE_TASK)\n ? FieldDefinitions::taskFollowupFieldsFilter()\n : FieldDefinitions::eventFollowupFieldsFilter();\n\n foreach ($fieldFilter as $eachFilter) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $eachFilter);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n /**\n * This one is now called only when ImportActivityTypes is triggered or SyncFieldMetadata executed manually\n * Regular sync now uses SharedSyncFieldsTrait -> syncSingleObjectType\n * Needs to be replaced later on\n */\n public function syncField(Field $field): void\n {\n try {\n if (FieldHelper::isCustomField($field)) {\n $query = '\n SELECT\n Id, Metadata, TableEnumOrId\n FROM\n CustomField\n WHERE\n DeveloperName = :fieldName\n AND\n TableEnumOrId = :fieldType\n AND\n NamespacePrefix = :namespacePrefix';\n\n // We need to constrain the field lookup to the object, in case it's used in multiple places.\n $objectType = \\in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true)\n ? 'activity'\n : $field->object_type;\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'fieldName' => substr($field->crm_provider_id, 0, -\\strlen('__c')),\n 'fieldType' => ucfirst($objectType),\n\n // This is used to ensure we only consider the field within the org, not installed packages.\n 'namespacePrefix' => 'null',\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n // Sync field metadata.\n $metadata = $sfField['Metadata'];\n\n $field->description = mb_strimwidth($metadata['description'] ?? '', 0, 191);\n $field->label = mb_strimwidth($metadata['label'] ?? '', 0, Field::LABEL_MAX_LENGTH);\n $field->type = $this->convertFieldType($metadata['type'], $field->getEntityName());\n $field->is_mandatory = ($metadata['required'] === true);\n $field->length = $metadata['length'];\n $field->default_value = mb_strimwidth(trim($metadata['defaultValue'] ?? '', '\"'), 0, 191);\n $field->save();\n } else {\n $query = '\n SELECT\n Id, DataType, DeveloperName, Label, Length, Description\n FROM\n FieldDefinition\n WHERE\n DurableId = :entityName';\n\n $entityName = $field->getEntityName();\n $sfFields = $this->queryHandler->metadata($query, [\n 'entityName' => $entityName,\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n $convertedType = $this->convertFieldType($sfField['DataType'], $entityName);\n $label = mb_strimwidth($sfField['Label'], 0, Field::LABEL_MAX_LENGTH);\n\n if ($field->isBusinessType()) {\n $label = 'Opportunity Type';\n }\n\n $field->description = mb_strimwidth($sfField['Description'], 0, Field::DESCRIPTION_MAX_LENGTH);\n $field->label = $label;\n $field->type = $convertedType;\n $field->length = $sfField['Length'];\n $field->save();\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n private function convertFieldType(string $from, ?string $entityName = null): string\n {\n $converter = new FieldTypeConverter();\n\n return $converter->convert($from, $entityName);\n }\n\n /**\n * @inheritdoc\n */\n public function importPicklistValues(Field $field): array\n {\n $values = [];\n $fieldValues = [];\n\n try {\n if (FieldHelper::isCustomField($field)) {\n $query = '\n SELECT\n Id, Metadata, TableEnumOrId\n FROM\n CustomField\n WHERE\n DeveloperName = :fieldName\n AND\n TableEnumOrId = :fieldType\n AND\n NamespacePrefix = :namespacePrefix';\n\n // We need to constrain the field lookup to the object, in case it's used in multiple places.\n $objectType = \\in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true) ?\n 'activity' : $field->object_type;\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'fieldName' => substr($field->crm_provider_id, 0, -\\strlen('__c')),\n 'fieldType' => ucfirst($objectType),\n // This is used to ensure we only consider the field within the org, not installed packages.\n 'namespacePrefix' => 'null',\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n $valueSet = $sfField['Metadata']['valueSet'];\n\n if ($valueSet['valueSetName'] === null) {\n // Local picklist values can be obtained easily.\n $picklistValues = $valueSet['valueSetDefinition']['value'];\n } else {\n // But for some fields, we just get the Global Value Picklist pointer so need to do more work.\n $picklistValues = $this->importGlobalValuePicklistValues($valueSet['valueSetName']);\n }\n\n // Import all active values.\n foreach ($picklistValues as $i => $sfFieldValue) {\n // Setup default value.\n if ($sfFieldValue['default']) {\n $field->update(['default_value' => $sfFieldValue['valueName']]);\n }\n\n // This comes through as null if active (lol).\n if ($sfFieldValue['isActive'] !== false) {\n $values[] = [\n 'value' => $sfFieldValue['valueName'],\n 'label' => $sfFieldValue['valueName'],\n 'sequence' => $i,\n 'is_default' => $sfFieldValue['default'],\n ];\n }\n }\n } else {\n $objectFields = $this->getObjectFields($field->object_type);\n $fieldId = $field->crm_provider_id;\n\n // Only work with our field of interest.\n $objectField = array_filter($objectFields, function ($item) use ($fieldId) {\n return $item['name'] === $fieldId;\n });\n\n $objectField = array_shift($objectField);\n if (empty($objectField['picklistValues']) === false) {\n foreach ($objectField['picklistValues'] as $i => $sfFieldValue) {\n // Skip inactive values.\n if ($sfFieldValue['active'] === false) {\n continue;\n }\n\n // Setup default value.\n if ($sfFieldValue['defaultValue']) {\n $field->update(['default_value' => $sfFieldValue['value']]);\n }\n\n $values[] = [\n 'value' => $sfFieldValue['value'],\n 'label' => $sfFieldValue['label'],\n 'sequence' => $i,\n 'is_default' => $sfFieldValue['defaultValue'],\n ];\n }\n }\n }\n\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n $value['value'] = substr($value['value'] ?? '', 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n if (($key = array_search($value['value'], $fieldsToPurge, true)) !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n // Get IDs of the values to be deleted\n $valuesToDelete = $field->values()->whereIn('value', $fieldsToPurge);\n $valuesToDeleteIds = $valuesToDelete->pluck('id');\n if (! $valuesToDeleteIds->isEmpty()) {\n $recordTypeFieldValuesRepository = app(RecordTypeFieldValuesRepository::class);\n $recordTypeFieldValuesRepository->deleteForCrmFieldValueIds($valuesToDeleteIds->toArray());\n\n // Now safely delete from crm_field_values\n $valuesToDelete->delete();\n }\n\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n return $fieldValues;\n }\n\n /**\n * Gets values from Global Value Picklists.\n */\n private function importGlobalValuePicklistValues(string $picklistName): array\n {\n $query = '\n SELECT\n Metadata\n FROM\n GlobalValueSet\n WHERE\n DeveloperName = :picklistName\n LIMIT 1';\n\n try {\n $sfValues = $this->queryHandler->metadata($query, [\n 'picklistName' => $picklistName,\n ]);\n\n // There is always 1 result at this point.\n $sfValue = $sfValues->current();\n\n return $sfValue['Metadata']['customValue'];\n } catch (NoResultsException $noResultsException) {\n // Nothing returned.\n\n return [];\n }\n }\n\n /**\n * @inheritdoc\n */\n public function syncProfileRecordTypes(): void\n {\n $objectTypes = [\n 'lead',\n 'account',\n 'contact',\n 'opportunity',\n 'task',\n 'event',\n ];\n\n foreach ($objectTypes as $objectType) {\n try {\n $crmRecordTypes = $this->getRecordTypes(ucfirst($objectType));\n\n foreach ($crmRecordTypes as $crmRecordType) {\n // If the record type is default and not the Master type, set this.\n if ($crmRecordType['default'] && $crmRecordType['recordTypeId'] !== '012000000000000AAA') {\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $crmRecordType['recordTypeId'])\n ->first();\n\n if ($recordType) {\n $this->profile->{$objectType . '_record_type_id'} = $recordType->id;\n }\n }\n }\n } catch (HttpNotFoundException $exception) {\n Log::error('No access to ' . $objectType . ' object, skipping...');\n\n // XXX: should we log this fact somewhere?\n continue;\n }\n }\n\n if ($this->profile->isDirty()) {\n $this->profile->save();\n }\n }\n\n /**\n * Gets business processes.\n */\n public function importBusinessProcesses(): void\n {\n $query = '\n SELECT\n Id, IsActive, Name, TableEnumOrId\n FROM\n BusinessProcess\n WHERE\n TableEnumOrId IN (\\'Lead\\',\\'Opportunity\\')';\n\n try {\n $sfProcesses = $this->queryHandler->query($query);\n\n // Upsert all processes for the team.\n foreach ($sfProcesses as $sfProcess) {\n /** @var BusinessProcess $businessProcess */\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $sfProcess['Id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => $sfProcess['Name'],\n 'type' => $sfProcess['TableEnumOrId'] === 'Lead' ? 'lead' : 'opportunity',\n 'is_selectable' => $sfProcess['IsActive'],\n ]);\n\n $this->importBusinessProcessStages($businessProcess);\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n /**\n * Gets business process stages.\n */\n private function importBusinessProcessStages(BusinessProcess $businessProcess): void\n {\n $query = '\n SELECT\n Metadata\n FROM\n BusinessProcess\n WHERE\n Id = :processId';\n\n try {\n $stages = [];\n $sfProcessStages = $this->queryHandler->metadata($query, [\n 'processId' => $businessProcess->crm_provider_id,\n ]);\n\n // There is always 1 result at this point.\n $sfProcessStage = $sfProcessStages->current();\n\n // Upsert all processes for the team.\n foreach ($sfProcessStage['Metadata']['values'] as $sfProcessStage) {\n $sanitizedName = urldecode($sfProcessStage['valueName']); // Must decode: \"%2C\" becomes \",\" etc.\n\n $stage = $businessProcess->crm->stages()\n // This MUST match on label because this API doesn't use API Name.\n ->where('label', $sanitizedName)\n ->where('type', $businessProcess->type)\n ->where('is_selectable', 1)\n ->first();\n\n if ($stage) {\n $stages[] = $stage->id;\n }\n }\n\n $businessProcess->stages()->sync($stages);\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n /**\n * Gets record types.\n */\n public function importRecordTypes(): void\n {\n $query = '\n SELECT\n Id, IsActive, Name, BusinessProcessId, SobjectType\n FROM\n RecordType';\n\n try {\n $sfRecordTypes = $this->queryHandler->query($query);\n\n // Upsert all record types for the process.\n foreach ($sfRecordTypes as $sfRecordType) {\n $businessProcess = null;\n if ($sfRecordType['BusinessProcessId']) {\n $businessProcess = $this->config->businessProcesses()\n ->where('crm_provider_id', $sfRecordType['BusinessProcessId'])\n ->first();\n }\n\n /** @var RecordType $recordType */\n $recordType = $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $sfRecordType['Id'],\n ], [\n 'team_id' => $this->team->id,\n 'type' => mb_strtolower($sfRecordType['SobjectType']),\n 'name' => $sfRecordType['Name'],\n 'is_selectable' => $sfRecordType['IsActive'],\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n $this->importRecordTypeFieldValues($recordType);\n }\n } catch (NoResultsException $noResultsException) {\n // Do nothing.\n }\n }\n\n /**\n * Import record type - field value mappings. This only works for standard fields.\n */\n private function importRecordTypeFieldValues(RecordType $recordType): void\n {\n try {\n $query = '\n SELECT\n Metadata\n FROM\n RecordType\n WHERE\n Id = :recordTypeId';\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'recordTypeId' => $recordType->crm_provider_id,\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n // Sync field metadata.\n $picklists = $sfField['Metadata']['picklistValues'];\n\n foreach ($picklists as $picklist) {\n $field = $this->config->fields()->where([\n 'type' => Field::TYPE_PICKLIST,\n 'object_type' => $recordType->type,\n 'crm_provider_id' => $picklist['picklist'],\n ])->first();\n\n if ($field) {\n $fieldValues = [];\n\n foreach ($picklist['values'] as $value) {\n // Must decode: \"%2C\" becomes \",\" etc.\n $fieldValue = $field->values()\n ->where('value', urldecode($value['valueName']))\n ->first();\n\n if ($fieldValue) {\n $fieldValues[] = $fieldValue->id;\n }\n }\n\n $recordType->fieldValues()->sync($fieldValues);\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n /**\n * @inheritdoc\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $params = [];\n $missingStage = null;\n if ($types === null) {\n $types = [Stage::TYPE_LEAD, Stage::TYPE_OPPORTUNITY];\n }\n\n foreach ($types as $type) {\n if ($type === Stage::TYPE_LEAD) {\n $query = '\n SELECT\n Id, ApiName, MasterLabel, SortOrder\n FROM\n LeadStatus';\n } else {\n $query = '\n SELECT\n Id, ApiName, MasterLabel, IsActive, SortOrder, DefaultProbability\n FROM\n OpportunityStage';\n }\n\n if ($missingStageName) {\n $escapedStageName = ValueNormalizer::replaceQueryWithStringLiterals($missingStageName);\n\n $query .= ' WHERE ApiName = :stageName';\n\n $params = [\n 'stageName' => $escapedStageName,\n ];\n }\n\n try {\n $sfStages = $this->queryHandler->query($query, $params);\n } catch (NoResultsException $exception) {\n $sfStages = [];\n }\n\n $missingStage = null;\n\n // Upsert all stages for the team.\n foreach ($sfStages as $sfStage) {\n $selectable = true;\n if (array_key_exists('IsActive', $sfStage)) {\n $selectable = $sfStage['IsActive'];\n }\n\n $this->restoreAnyTrashedEntity($this->config->stages(), $sfStage['Id']);\n\n $stage = $this->config->stages()->updateOrCreate([\n 'crm_provider_id' => $sfStage['Id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($sfStage['ApiName'], 0, 50),\n 'label' => mb_strimwidth($sfStage['MasterLabel'], 0, 191),\n 'type' => $type,\n 'sequence' => $sfStage['SortOrder'] ?? 0,\n 'is_selectable' => $selectable,\n 'probability' => $sfStage['DefaultProbability'] ?? null,\n ]);\n\n if ($missingStageName && $missingStageName === $sfStage['ApiName']) {\n $missingStage = $stage;\n }\n }\n\n if ($missingStageName && $missingStage === null) {\n // If they requested a stage that still doesn't exist, it must be inactive so lazy create it.\n $missingStage = $this->config->stages()->create([\n 'crm_provider_id' => Uuid::uuid4(),\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($missingStageName, 0, 50),\n 'label' => mb_strimwidth($missingStageName, 0, 191),\n 'type' => $type,\n 'sequence' => 0,\n 'is_selectable' => 0,\n ]);\n }\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int\n {\n $syncCount = 0;\n $fields = $this->getAllFieldsAsArray('lead');\n if (\\in_array('Id', $fields, true) === false) {\n return $syncCount;\n }\n\n $query = '\n SELECT ' . rtrim(implode(',', $fields), ',') . '\n FROM Lead\n WHERE LastModifiedDate > :since\n ORDER BY LastModifiedDate ASC';\n\n try {\n $sfLeads = $this->queryHandler->query($query, [\n 'since' => $since->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($sfLeads as $sfLead) {\n // Only sync if previously imported.\n if ($this->hasLead($sfLead['Id'])) {\n $this->importLead($sfLead);\n $syncCount++;\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::LEAD);\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLead(string $crmId): ?Lead\n {\n $fields = $this->getAllFieldsAsArray('lead');\n\n $sfLead = $this->getRecord('Lead', $crmId, $fields);\n\n return $this->importLead($sfLead);\n }\n\n private function importLead($crmData): ?Lead\n {\n /** @var ?Stage $stage */\n $stage = null;\n if (isset($crmData['Status'])) {\n // Get the current stage.\n $stage = $this->config\n ->stages()\n ->where('name', $crmData['Status'])\n ->where('type', Stage::TYPE_LEAD)\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_LEAD], $crmData['Status']);\n }\n }\n\n // If we have no way of importing this, just return null :(\n if ($stage === null) {\n return null;\n }\n\n $countryCode = $crmData['CountryCode'] ?? null;\n\n // Salesforce allows custom \"countries\" to be created. Disregard these.\n if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {\n $countryCode = null;\n }\n\n // If we have no country code, try to parse it from the country name.\n if ($countryCode === null && empty($crmData['Country']) !== false) {\n $countryCode = $this->convertCountryNameToCode($crmData['Country']);\n }\n\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($crmData['Phone'] ?? '', 0, 25);\n $parsedNumber = parsePhoneNumber($countryCode, $number);\n\n $mobilePhone = null;\n if (empty($crmData['MobilePhone']) === false) {\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($crmData['MobilePhone'], 0, 25);\n $mobilePhone = phone_e164($countryCode, $number);\n }\n\n $convertedDate = null;\n $convertedAccount = null;\n $convertedOpportunity = null;\n $convertedContact = null;\n\n if ($crmData['IsConverted'] == 'true') {\n $convertedDate = $crmData['ConvertedDate'];\n\n if (empty($crmData['ConvertedAccountId']) === false) {\n $convertedAccount = $this->config\n ->accounts()\n ->where('crm_provider_id', $crmData['ConvertedAccountId'])\n ->first();\n\n if ($convertedAccount === null) {\n try {\n $convertedAccount = $this->syncAccount($crmData['ConvertedAccountId']);\n } catch (HttpNotFoundException $exception) {\n // Probably the user has no permissions to access the converted data.\n }\n }\n }\n\n if (empty($crmData['ConvertedOpportunityId']) === false) {\n $convertedOpportunity = $this->config\n ->opportunities()\n ->where('crm_provider_id', $crmData['ConvertedOpportunityId'])\n ->first();\n\n if ($convertedOpportunity === null) {\n try {\n $convertedOpportunity = $this->syncOpportunity($crmData['ConvertedOpportunityId']);\n } catch (HttpNotFoundException $exception) {\n // Probably the user has no permissions to access the converted data.\n }\n }\n }\n\n if (empty($crmData['ConvertedContactId']) === false) {\n $convertedContact = $this->team\n ->crm\n ->contacts()\n ->where('crm_provider_id', $crmData['ConvertedContactId'])\n ->first();\n\n if ($convertedContact === null) {\n try {\n $convertedContact = $this->syncContact($crmData['ConvertedContactId']);\n } catch (HttpNotFoundException $exception) {\n // Probably the user has no permissions to access the converted data.\n }\n }\n }\n }\n\n if (empty($crmData['Company'])) {\n $company = 'Unknown';\n } else {\n $company = mb_strimwidth($crmData['Company'], 0, 191);\n }\n\n $domain = null;\n if (empty($crmData['Website']) === false) {\n $domain = mb_strimwidth($crmData['Website'], 0, 191);\n $domain = StringUtil::resolveDomain($domain);\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $data = [\n 'team_id' => $this->team->id,\n 'user_id' => $profile?->user_id,\n 'owner_id' => $crmData['OwnerId'] ?? '',\n 'company' => $company,\n 'domain' => $domain,\n 'name' => $crmData['Name'] ? mb_strimwidth($crmData['Name'], 0, 191) : '',\n 'title' => $crmData['Title'] ? mb_strimwidth($crmData['Title'], 0, 128) : null,\n 'email' => $crmData['Email'] ? mb_strimwidth($crmData['Email'], 0, 80) : null,\n 'phone' => $parsedNumber['phone'],\n 'ext' => $parsedNumber['ext'] ?? null,\n 'mobile_phone' => $mobilePhone,\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['Id'],\n modelType: Lead::class,\n fileName: $crmData['Id'],\n avatarText: $crmData['Name']\n ),\n 'stage_id' => $stage->id,\n 'record_type_id' => null,\n 'converted_at' => $convertedDate,\n 'converted_account_id' => $convertedAccount->id ?? null,\n 'converted_opportunity_id' => $convertedOpportunity->id ?? null,\n 'converted_contact_id' => $convertedContact->id ?? null,\n 'country_code' => $countryCode,\n 'remotely_created_at' => $createdDate,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->leads(), $crmData['Id']);\n\n /** @var Lead $lead */\n $lead = $this->config->leads()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n if ($lead->wasChanged('converted_at') && $lead->getConvertedAt() !== null) {\n $this->eventDispatcher->dispatch(new LeadConverted($lead));\n }\n\n $this->handleObjectDeletion($lead, $crmData);\n\n return $lead;\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccounts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n $fields = $this->getAllFieldsAsArray('account');\n\n if (\\in_array('Id', $fields, true) === false) {\n return $syncCount;\n }\n\n $query = '\n SELECT ' . rtrim(implode(',', $fields), ',') . '\n FROM Account\n WHERE LastModifiedDate > :since\n ORDER BY LastModifiedDate ASC';\n\n try {\n $sfAccounts = $this->queryHandler->query($query, [\n 'since' => $since->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($sfAccounts as $sfAccount) {\n // Only sync if previously imported.\n if ($this->hasAccount($sfAccount['Id'])) {\n $this->importAccount($sfAccount);\n $syncCount++;\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::ACCOUNT);\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccount(string $crmId): ?Account\n {\n $fields = $this->getAllFieldsAsArray('account');\n if (! in_array('Id', $fields, true)) {\n $this->logger->info('[Salesforce] Sync account cancelled. Fields are not available.', [\n 'crmId' => $crmId,\n 'userId' => $this->profile->getUserId(),\n ]);\n\n return null;\n }\n\n $sfAccount = $this->getRecord('Account', $crmId, $fields);\n\n return $this->importAccount($sfAccount);\n }\n\n private function importAccount($crmData): Account\n {\n $countryCode = $crmData['BillingCountryCode'] ?? $crmData['ShippingCountryCode'] ?? null;\n\n // Salesforce allows custom \"countries\" to be created. Disregard these.\n if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {\n $countryCode = null;\n }\n\n // If we have no country code, try to parse it from the country names.\n if ($countryCode === null && empty($crmData['BillingCountry']) === false) {\n $countryCode = $this->convertCountryNameToCode($crmData['BillingCountry']);\n }\n\n if ($countryCode === null && empty($crmData['ShippingCountry']) === false) {\n $countryCode = $this->convertCountryNameToCode($crmData['ShippingCountry']);\n }\n\n if (empty($crmData['Phone']) === false) {\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($crmData['Phone'], 0, 25);\n $parsedNumber = parsePhoneNumber($countryCode, $number);\n } else {\n $parsedNumber = [];\n }\n\n $industry = null;\n if (empty($crmData['Industry']) === false) {\n $industry = mb_strimwidth($crmData['Industry'], 0, 40);\n }\n\n $domain = null;\n if (empty($crmData['Website']) === false) {\n $domain = mb_strimwidth($crmData['Website'], 0, 191);\n $domain = StringUtil::resolveDomain($domain);\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $data = [\n 'team_id' => $this->team->id,\n 'user_id' => $profile?->user_id,\n 'owner_id' => $crmData['OwnerId'],\n 'name' => mb_strimwidth($crmData['Name'], 0, 191),\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['Id'],\n modelType: Account::class,\n fileName: $crmData['Id'],\n avatarText: $crmData['Name']\n ),\n 'industry' => $industry,\n 'domain' => $domain,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'ext' => $parsedNumber['ext'] ?? null,\n 'country_code' => $countryCode,\n 'remotely_created_at' => $createdDate,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->accounts(), $crmData['Id']);\n\n /** @var Account $account */\n $account = $this->config->accounts()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n $this->handleObjectDeletion($account, $crmData);\n\n return $account;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOpportunities(array $parameters, ?string $strategy = null): int\n {\n $strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);\n\n $syncCount = 0;\n $logParams = $parameters;\n $parameters['profile'] = $this->profile;\n $logParams['user'] = $this->profile->getUserId();\n\n if (count($strategies) > 1) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Multiple sync strategies used', [\n 'teamId' => $this->team->getUuid(),\n 'params' => $logParams,\n 'strategies_count' => count($strategies),\n ]);\n }\n\n foreach ($strategies as $syncStrategy) {\n $name = $syncStrategy->getStrategyName();\n\n try {\n $sfOpportunities = $syncStrategy->fetchOpportunities($parameters);\n $totalRecords = $sfOpportunities->count();\n\n foreach ($sfOpportunities as $sfOpportunity) {\n $this->importOpportunity($sfOpportunity);\n $syncCount++;\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n $this->logger->warning('[' . $this->getDisplayName() . '] No opportunities found', [\n 'teamId' => $this->team->getUuid(),\n 'name' => $name,\n 'params' => $logParams,\n 'reason' => $noResultsException->getMessage(),\n ]);\n } catch (CrmException $crmException) {\n // Nothing to sync.\n $this->logger->warning('[' . $this->getDisplayName() . '] Opportunity sync failed', [\n 'teamId' => $this->team->getUuid(),\n 'name' => $name,\n 'params' => $logParams,\n 'reason' => $crmException->getMessage(),\n ]);\n }\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::OPPORTUNITY, ['params' => $logParams]);\n\n // debug to see how if count of opportunities reaches 1000\n if ($syncCount >= 1000) {\n $this->logger->info(\n '[' . $this->getDisplayName() . '] Sync Opportunities - count warning',\n [\n 'team_id' => $this->team->getId(),\n 'params' => $logParams,\n 'count' => $syncCount,\n 'strategies_count' => count($strategies),\n 'total_records' => $totalRecords ?? null,\n ]\n );\n }\n\n return $syncCount;\n }\n\n\n /**\n * @inheritdoc\n */\n public function syncOpportunity(string $crmId): ?Opportunity\n {\n $strategy = $this->opportunitySyncStrategyResolver->resolve(\n $this->config,\n OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY\n );\n\n $parameters = [\n 'profile' => $this->profile,\n 'crm_id' => $crmId,\n ];\n\n try {\n $sfOpportunity = $strategy->fetchOpportunities($parameters);\n } catch (HttpNotFoundException $e) {\n $this->logger->info('[' . $this->getDisplayName() . '] Opportunity not found', [\n 'teamId' => $this->team->id_string,\n 'crmId' => $crmId,\n ]);\n\n return null;\n } catch (CrmException $crmException) {\n $this->logger->info('[' . $this->getDisplayName() . '] Opportunity sync failed', [\n 'teamId' => $this->team->id_string,\n 'crmId' => $crmId,\n 'exception' => $crmException->getMessage(),\n ]);\n\n return null;\n }\n\n if ($sfOpportunity instanceof ArrayIterator) {\n return $this->importOpportunity($sfOpportunity->getItems());\n }\n\n return $this->importOpportunity($sfOpportunity);\n }\n\n /**\n * @throws HttpNotFoundException\n */\n private function importOpportunity($crmData): ?Opportunity\n {\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $account = null;\n if (empty($crmData['AccountId']) === false) {\n /** @var ?Account $account */\n $account = $this->config->accounts()\n ->where('crm_provider_id', (string) $crmData['AccountId'])\n ->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmData['AccountId']);\n }\n }\n\n $userId = $profile?->getUserId() ?? $account?->getUserId();\n if ($userId === null) {\n $this->logger->error('[Salesforce] | Skip import, no user_id found', [\n 'id' => $crmData['Id'],\n ]);\n\n return null;\n }\n\n /** @var ?Stage $stage */\n $stage = null;\n if (isset($crmData['StageName'])) {\n $stage = $this->config\n ->stages()\n ->where('name', $crmData['StageName'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->orderBy('is_selectable', 'DESC')\n ->orderBy('id')\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $crmData['StageName']);\n }\n }\n\n $recordType = null;\n if (empty($crmData['RecordTypeId']) === false) {\n /** @var ?RecordType $recordType */\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $crmData['RecordTypeId'])\n ->first();\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $closeDate = null;\n if (empty($crmData['CloseDate']) === false) {\n $closeDate = Carbon::parse($crmData['CloseDate'])->format('Y-m-d');\n }\n\n $valueFieldName = 'Amount';\n if ($this->config->opportunity_value_field_id) {\n $valueFieldName = $this->config->opportunityValueField->crm_provider_id;\n }\n\n $data = [\n 'team_id' => $this->team->id,\n 'account_id' => $account->id ?? null,\n 'user_id' => $userId,\n 'owner_id' => $crmData['OwnerId'] ?? null,\n 'name' => mb_strimwidth($crmData['Name'] ?? '', 0, 128),\n 'value' => $crmData[$valueFieldName],\n 'currency_code' => CurrencyFormatter::formatCode($crmData['CurrencyIsoCode'] ?? null),\n 'close_date' => $closeDate,\n 'is_closed' => $crmData['IsClosed'],\n 'is_won' => $crmData['IsWon'],\n 'stage_id' => $stage?->id ?? null,\n 'record_type_id' => $recordType->id ?? null,\n 'remotely_created_at' => $createdDate,\n 'probability' => $crmData['Probability'] ?? null,\n 'forecast_category' => $crmData['ForecastCategoryName'] ?? null,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->opportunities(), $crmData['Id']);\n\n // Do not allow locked DB tables & other errors\n // to interrupt the process of reverting the trashed opportunities\n try {\n /** @var Opportunity $opportunity */\n $opportunity = $this->config->opportunities()\n ->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n // import external fields into crm_field_data if present\n $crmFields = $this->getOpportunitySyncableFields();\n\n $this->importOpportunityCrmFieldData($crmData, $crmFields, $opportunity->id);\n\n $this->handleObjectDeletion($opportunity, $crmData);\n\n if ($opportunity->wasRecentlyCreated) {\n MatchActivitiesToNewOpportunity::dispatch($opportunity->getId());\n }\n\n return $opportunity;\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n $this->logger->error('[Salesforce] importOpportunity failure.', [\n 'crm_provider_id' => $crmData['Id'],\n 'team_id' => $this->team->id,\n 'exception' => $exception->getMessage(),\n ]);\n\n $this->handleEntityDeletionByProviderId($this->config->opportunities(), $crmData);\n }\n\n return null;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContacts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n $fields = $this->getAllFieldsAsArray('contact');\n if (\\in_array('Id', $fields, true) === false) {\n return $syncCount;\n }\n\n $query = '\n SELECT ' . rtrim(implode(',', $fields), ',') . '\n FROM Contact\n WHERE LastModifiedDate > :since\n ORDER BY LastModifiedDate ASC';\n\n try {\n $sfContacts = $this->queryHandler->query($query, [\n 'since' => $since->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($sfContacts as $sfContact) {\n // Only sync if previously imported.\n if ($this->hasContact($sfContact['Id'])) {\n $this->importContact($sfContact);\n $syncCount++;\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::CONTACT);\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContact(string $crmId): ?Contact\n {\n $fields = $this->getAllFieldsAsArray('contact');\n if (! in_array('Id', $fields, true)) {\n $this->logger->info('[Salesforce] Sync contact cancelled. Fields are not available.', [\n 'crmId' => $crmId,\n 'userId' => $this->profile->getUserId(),\n ]);\n\n return null;\n }\n\n $sfContact = $this->getRecord('Contact', $crmId, $fields);\n\n return $this->importContact($sfContact);\n }\n\n private function importContact($crmData): Contact\n {\n $account = null;\n // Contacts may not have accounts...\n if (isset($crmData['AccountId'])) {\n $account = $this->config->accounts()\n ->where('crm_provider_id', (string) $crmData['AccountId'])\n ->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmData['AccountId']);\n }\n }\n\n $countryCode = $crmData['MailingCountryCode'] ?? null;\n\n // Salesforce allows custom \"countries\" to be created. Disregard these.\n if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {\n $countryCode = null;\n }\n\n // If we have no country code, try to parse it from the country name.\n if ($countryCode === null && empty($crmData['MailingCountry']) === false) {\n $countryCode = $this->convertCountryNameToCode($crmData['MailingCountry']);\n\n if ($countryCode === null && $account) {\n $countryCode = $account->country_code;\n }\n }\n\n $ext = null;\n $parsedNumber = [];\n if (empty($crmData['Phone']) === false) {\n $number = Str::limit($crmData['Phone'], 25, '');\n $parsedNumber = parsePhoneNumber($countryCode, $number);\n\n if (empty($parsedNumber['ext']) === false) {\n $ext = Str::limit($parsedNumber['ext'], 10, '');\n }\n }\n\n $mobileNumber = null;\n if (empty($crmData['MobilePhone']) === false) {\n $mobileNumber = Str::limit(phone_e164($countryCode, $crmData['MobilePhone']), 25, '');\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $data = [\n 'team_id' => $this->team->id,\n 'account_id' => $account->id ?? null,\n 'user_id' => $profile?->user_id,\n 'owner_id' => $crmData['OwnerId'] ?? null,\n 'name' => ($crmData['Name'] ?? null) !== null ? mb_strimwidth($crmData['Name'], 0, 100) : '',\n 'title' => ($crmData['Title'] ?? null) !== null ? mb_strimwidth($crmData['Title'], 0, 128) : null,\n 'email' => ($crmData['Email'] ?? null) !== null ? mb_strimwidth($crmData['Email'], 0, 191) : null,\n 'country_code' => $countryCode,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'ext' => $ext,\n 'mobile_phone' => $mobileNumber,\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['Id'],\n modelType: Contact::class,\n fileName: $crmData['Id'],\n avatarText: $crmData['Name']\n ),\n 'remotely_created_at' => $createdDate,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->contacts(), $crmData['Id']);\n\n /** @var Contact $contact */\n $contact = $this->config->contacts()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n $this->handleObjectDeletion($contact, $crmData);\n\n return $contact;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n $fields = [\n 'InstanceName',\n 'OrganizationType',\n 'IsSandbox',\n ];\n\n $orgValues = $this->getRecord('Organization', $this->config->crm_provider_id, $fields);\n\n $edition = null;\n switch ($orgValues['OrganizationType']) {\n case 'Developer Edition':\n $edition = Configuration::EDITION_DEVELOPER;\n\n break;\n\n case 'Professional Edition':\n $edition = Configuration::EDITION_PROFESSIONAL;\n\n break;\n\n case 'Enterprise Edition':\n $edition = Configuration::EDITION_ENTERPRISE;\n\n break;\n }\n\n $this->config->edition = $edition;\n $this->config->instance = $orgValues['InstanceName'];\n\n // XXX: How can this state be possible?\n if ($this->config->version === null) {\n $this->config->version = Client::MIN_API_VERSION;\n }\n\n $installedVersion = $this->getInstalledAppVersion();\n if ($installedVersion !== null) {\n $installedVersion = (string) $this->getInstalledAppVersion();\n }\n\n $this->config->installed_app_version = $installedVersion;\n\n $this->config->save();\n }\n\n public function getInstalledAppVersion(): ?string\n {\n try {\n $query = '\n SELECT\n SubscriberPackageVersion.MajorVersion,\n SubscriberPackageVersion.MinorVersion,\n SubscriberPackageVersion.PatchVersion,\n SubscriberPackageVersion.BuildNumber\n FROM\n InstalledSubscriberPackage\n WHERE\n SubscriberPackageId = :packageId\n ';\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'packageId' => self::INSTALLED_PACKAGE_ID,\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n // Grab version number.\n $version = $sfField['SubscriberPackageVersion']['MajorVersion'] .\n $sfField['SubscriberPackageVersion']['MinorVersion'] .\n $sfField['SubscriberPackageVersion']['PatchVersion'] .\n $sfField['SubscriberPackageVersion']['BuildNumber'];\n } catch (\\Exception) {\n $version = null;\n }\n\n return $version;\n }\n\n /**\n * Store transcripts as note.\n *\n * @throws \\Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // For SF we also check if Log Notes is enabled.\n if ($this->profile->log_notes === Profile::LOG_NOTE_NONE) {\n return;\n }\n\n if ($activity->opportunity_id && $activity->prospect === null) {\n return;\n }\n\n try {\n $transcriptionData = $this->generateTranscription($activity);\n\n $noteMaxLength = $this->profile->log_notes === Profile::LOG_NOTE_ENHANCED\n ? self::ENHANCED_NOTE_MAX_LENGTH\n : self::CLASSIC_NOTE_MAX_LENGTH;\n\n $title = 'Transcript for ';\n $title .= $activity->title ?? $activity->activity_title;\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $body = mb_strimwidth($transcriptionData, 0, $noteMaxLength);\n\n if ($activity->opportunity_id) {\n $objectId = $activity->opportunity->crm_provider_id;\n } else {\n $objectId = $activity->prospect->crm_provider_id;\n }\n\n $noteId = $this->saveNote($title, $body, $objectId);\n\n // Store crm logged id in transcription.\n $transcription = $activity->getTranscription();\n $transcription->crm_activity_id = $noteId;\n $transcription->save();\n } catch (\\Exception $e) {\n \\Sentry::captureException($e);\n }\n }\n\n public function saveNote(string $title, string $body, string $objectId, ?NoteObject $noteObject = null): ?string\n {\n $noteId = null;\n\n try {\n if ($this->profile->log_notes === Profile::LOG_NOTE_ENHANCED) {\n $noteId = $this->buildEnhancedNote($title, $body, $objectId);\n } else {\n $noteId = $this->buildClassicNote($title, $body, $objectId);\n }\n } catch (HttpNotFoundException $exception) {\n // The profile not having access to create Enhanced Notes. Set their preference to Classic.\n if ($this->profile->log_notes === Profile::LOG_NOTE_ENHANCED) {\n $this->profile->update([\n 'log_notes' => Profile::LOG_NOTE_CLASSIC,\n ]);\n }\n }\n\n return $noteId;\n }\n\n /**\n * This is using the \"Enhanced\" Notes feature, NOT the \"Notes & Attachments\" feature being deprecated.\n *\n * @url https://salesforce.stackexchange.com/questions/104408/how-can-i-create-an-account-note-or-contact-note-via-api-that-is-visible-in-sale\n */\n private function buildEnhancedNote(string $title, string $body, string $objectId): string\n {\n // Decode stored entities, escape HTML (without quoting), then convert line breaks for Salesforce formatting\n $decodedBody = html_entity_decode($body, ENT_QUOTES | ENT_HTML5);\n $sanitizedBody = htmlspecialchars($decodedBody, ENT_NOQUOTES, 'UTF-8', false);\n $content = nl2br($sanitizedBody, false);\n $note = [\n 'OwnerId' => $this->profile->crm_provider_id,\n 'Title' => $title,\n 'Content' => base64_encode($content),\n ];\n\n $noteId = $this->createRecord('ContentNote', $note);\n\n $link = [\n 'ContentDocumentId' => $noteId,\n 'LinkedEntityId' => $objectId,\n 'ShareType' => 'I',\n ];\n\n $this->createRecord('ContentDocumentLink', $link);\n\n return $noteId;\n }\n\n private function buildClassicNote(string $title, string $body, string $objectId): string\n {\n if (in_array($this->parseObjectType($objectId), [Field::OBJECT_TASK, Field::OBJECT_EVENT])) {\n $this->logger->info('[Salesforce] Summary not sent', [\n 'profile_id' => $this->profile->id,\n 'objectId' => $objectId,\n 'reason' => 'Classical Note does not support Task/Event relation',\n ]);\n\n return '';\n }\n\n $titleTrimmed = null;\n\n if (mb_strlen($title) > 80) {\n $titleTrimmed = substr($title, 0, 77) . '...';\n }\n $payload = [\n 'OwnerId' => $this->profile->crm_provider_id,\n 'IsPrivate' => false,\n 'Title' => $titleTrimmed ?? $title,\n 'Body' => $titleTrimmed ? $title . PHP_EOL . $body : $body,\n 'ParentId' => $objectId,\n ];\n\n return $this->createRecord('Note', $payload);\n }\n\n /**\n * @inheritdoc\n */\n public function find(string $name, array $scopes): array\n {\n if ($this->profile === null) {\n return [];\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $limitValues = ['limit' => $this->limit, 'offset' => $this->offset];\n $sosl = $queryBuilder->buildFindQuery($name, $scopes, $limitValues);\n\n $this->logger->info('[Salesforce] Find prospects', [\n 'profile_id' => $this->profile->id,\n 'sosl_query' => $sosl,\n 'search_string' => $name,\n 'scopes' => $scopes,\n ]);\n\n $data = Cache::remember($this->profile->id . $sosl, self::CACHE_TTL, function () use ($sosl) {\n $data = [];\n\n try {\n // Hit remote API.\n $objects = $this->queryHandler->search($sosl);\n\n // Build mapped list.\n foreach ($objects as $object) {\n $type = strtolower($object['attributes']['type']);\n\n $record = [\n 'crmId' => $object['Id'],\n 'name' => $object['Name'],\n 'prospectType' => $type,\n 'phoneNumbers' => [],\n 'crmUrl' => $this->generateProviderUrl($object['Id'], $type),\n ];\n\n switch ($type) {\n case 'lead':\n if (empty($object['Company']) === false) {\n $record['organization'] = $object['Company'];\n }\n\n if (empty($object['Title']) === false) {\n $record['title'] = $object['Title'];\n }\n\n $stage = $this->config->stages()\n ->where('type', Stage::TYPE_LEAD)\n ->where('name', $object['Status'])\n ->first();\n\n // Lazy create the stage.\n if ($stage === null) {\n $stage = $this->importStages([Stage::TYPE_LEAD], $object['Status']);\n }\n\n if ($stage) {\n $record += [\n 'stage' => [\n 'id' => $stage->id_string,\n 'name' => $stage->name,\n ],\n ];\n }\n\n if (empty($object['RecordTypeId']) === false) {\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $object['RecordTypeId'])\n ->first();\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n }\n\n break;\n\n case 'account':\n if (empty($object['Industry']) === false) {\n $record['industry'] = $object['Industry'];\n $record['detailsLine'] = $object['Industry'];\n }\n if (! empty($object['PersonEmail'])) {\n $record['detailsLine'] = $object['PersonEmail'];\n }\n\n break;\n\n case 'contact':\n // For contacts, we should try and fetch their account name too.\n if ($object['AccountId']) {\n // Cheaper to get this locally.\n $account = $this->config->accounts()\n ->where('crm_provider_id', $object['AccountId'])\n ->first(['name']);\n\n if ($account) {\n $record['organization'] = $account->name;\n }\n }\n\n if (! empty($object['IsPersonAccount']) && $object['Email']) {\n $record['detailsLine'] = $object['Email'];\n } else {\n if (empty($object['Title']) === false) {\n $record['title'] = $object['Title'];\n }\n }\n\n break;\n }\n\n // Add phone numbers to record.\n if (empty($object['Phone']) === false && $object['Phone']) {\n $record['phoneNumbers'][] = [\n 'number' => $object['Phone'],\n 'nationalFormat' => phone_national($this->profile->user->country_code, $object['Phone']),\n 'type' => 'phone',\n ];\n }\n\n if (empty($object['MobilePhone']) === false && $object['MobilePhone']) {\n $record['phoneNumbers'][] = [\n 'number' => $object['MobilePhone'],\n 'nationalFormat' => phone_national(\n $this->profile->user->country_code,\n $object['MobilePhone']\n ),\n 'type' => 'mobile',\n ];\n }\n\n $data[] = $record;\n }\n } catch (NoResultsException $e) {\n $data = [];\n }\n\n return $data;\n });\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n\n if ($crmAccountId === null) {\n return $data;\n }\n\n if ($userId) {\n $profileRepository = app(ProfileRepository::class);\n $profile = $profileRepository->findProfileByUserId($this->config, $userId);\n\n $ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;\n }\n\n try {\n // Perhaps their profile has no opportunity permissions.\n if ($this->profile === null || $this->profile->opportunity_fields === null) {\n return $data;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $query = $queryBuilder->buildFindOpportunitiesQuery();\n\n $objects = $this->queryHandler->query($query, ['accountId' => $crmAccountId]);\n\n foreach ($objects as $object) {\n $record = [\n 'crmId' => $object['Id'],\n 'name' => $object['Name'],\n 'won' => $object['IsWon'],\n 'closed' => $object['IsClosed'],\n ];\n\n $valueFieldName = 'Amount';\n if ($this->config->opportunity_value_field_id) {\n $valueFieldName = $this->config->opportunityValueField->crm_provider_id;\n }\n\n if (empty($object[$valueFieldName]) === false) {\n $currency = $object['CurrencyIsoCode'] ?? $this->config->default_currency;\n $value = formatCurrency($object[$valueFieldName], $currency);\n\n $record += [\n 'value' => $value,\n ];\n }\n\n $stage = $this->config->stages()\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->where('name', $object['StageName'])\n ->first();\n\n // Lazy create the stage.\n if ($stage === null) {\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $object['StageName']);\n }\n\n $record += [\n 'stage' => [\n 'id' => $stage->id_string,\n 'name' => $stage->name,\n ],\n ];\n\n if (empty($object['RecordTypeId']) === false) {\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $object['RecordTypeId'])\n ->first();\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n }\n\n if ($ownerId && isset($object['OwnerId']) && $object['OwnerId'] === $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n } catch (NoResultsException $e) {\n return $data;\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n public function getContactRolesFromCrm(?Carbon $since = null): array\n {\n $roles = [];\n\n if ($this->profile === null) {\n return $roles;\n }\n\n $queryBuilder = app(QueryBuilder::class, ['profile' => $this->profile]);\n\n $query = $queryBuilder->buildGetContactRolesQuery($since);\n\n try {\n $objects = $this->queryHandler->query($query);\n\n foreach ($objects as $object) {\n $roles[] = [\n 'id' => $object['Id'],\n 'contactId' => $object['ContactId'],\n 'opportunityId' => $object['OpportunityId'],\n 'ownerId' => $object['Opportunity']['OwnerId'] ?? null,\n 'isPrimary' => $object['IsPrimary'],\n 'role' => $object['Role'],\n ];\n }\n } catch (NoResultsException) {\n // Just return an empty array.\n $this->logger->info('[Salesforce] No contact roles found', [\n 'since' => $since?->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n }\n\n return $roles;\n }\n\n public function syncContactRoles(Carbon $since): int\n {\n $contactRoleRepository = app(ContactRoleRepository::class);\n\n $crmContactRoles = $this->getContactRolesFromCrm(since: $since);\n $syncCount = 0;\n $contactRoles = [];\n\n foreach ($crmContactRoles as $crmContactRole) {\n $contactRoles[] = $this->importContactRole($crmContactRole);\n $syncCount++;\n }\n\n $contactRoleRepository->saveContactRoles($contactRoles);\n\n $this->syncRemotelyDeletedContactRoles();\n\n return $syncCount;\n }\n\n private function importContactRole(array $contactRole): array\n {\n $contact = $this->config->contacts()\n ->where('crm_provider_id', $contactRole['contactId'])\n ->first();\n\n if ($contact === null) {\n $contact = $this->syncContact($contactRole['contactId']);\n }\n\n $opportunity = $this->config->opportunities()\n ->where('crm_provider_id', $contactRole['opportunityId'])\n ->first();\n\n if ($opportunity === null) {\n $opportunity = $this->syncOpportunity($contactRole['opportunityId']);\n }\n\n $role = null;\n if (! empty($contactRole['role'])) {\n $role = mb_strimwidth($contactRole['role'], 0, 191);\n }\n\n return [\n 'crm_configuration_id' => $this->config->getId(),\n 'contact_id' => $contact->getId(),\n 'crm_provider_id' => $contactRole['id'],\n 'subject_type' => ContactRole::SUBJECT_TYPE_OPPORTUNITY,\n 'subject_id' => $opportunity->getId(),\n 'is_primary' => $contactRole['isPrimary'],\n 'role' => $role,\n ];\n }\n\n protected function syncRemotelyDeletedContactRoles(): bool\n {\n try {\n $deletedRemotely = $this->queryHandler->queryDeleted('OpportunityContactRole');\n } catch (NoResultsException $e) {\n return false;\n }\n\n $deletedOpportunities = $deletedRemotely->getResults();\n $deletedIds = array_column($deletedOpportunities, 'id');\n\n $contactRoleRepository = app(ContactRoleRepository::class);\n\n foreach (array_chunk($deletedIds, self::HARD_DELETE_CHUNK) as $chunk) {\n $contactRoleRepository->deleteContactRoles($chunk);\n\n $this->logger->info('[' . $this->getDisplayName() . '] Remotely deleted opportunities synced', [\n 'teamId' => $this->team->id_string,\n 'remotelyDeletedOpportunities' => $chunk,\n 'count' => count($chunk),\n ]);\n }\n\n return true;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = $objects = [];\n\n $hasWho = \\in_array($objectType, ['lead', 'contact']);\n $playbook = $this->getPlaybook($this->profile->user);\n $fields = array_merge(\n $this->profile->getFieldsAsArray(parent::OBJECT_TASK),\n $playbook && $playbook->activityField ? [$playbook->activityField->crm_provider_id] : []\n );\n\n // Query should default to any open call for that user.\n $query = '\n SELECT ' . implode(',', array_unique($fields)) . '\n FROM Task\n WHERE OwnerId = :ownerId\n AND IsArchived = false\n AND IsDeleted = false\n AND IsClosed = false\n AND (';\n\n if ($objectType === 'account') {\n // This covers tasks tied to a related contact or opportunity too.\n $query .= '\n AccountId = :accountId';\n }\n\n if ($hasWho) {\n $query .= '\n WhoId = :whoId';\n\n // If we are also going to check on a specific opportunity, set that up.\n if ($opportunityId) {\n $query .= ' OR WhatId = :whatId';\n }\n }\n\n $query .= ' ) ORDER BY LastModifiedDate DESC';\n\n try {\n $objects = $this->queryHandler->query($query, [\n 'ownerId' => $this->profile->crm_provider_id,\n 'whoId' => $objectId,\n 'whatId' => $opportunityId,\n 'accountId' => $objectId,\n ]);\n } catch (NoResultsException $e) {\n return $data;\n } finally {\n $this->logger->debug(sprintf('[Salesforce] Found %s tasks for query \"%s\"', count($objects), $query));\n }\n\n foreach ($objects as $object) {\n $dueDate = $object['ActivityDate'] ? Carbon::parse($object['ActivityDate'])->toIso8601String() : null;\n $data[] = [\n 'crmId' => $object['Id'],\n 'subject' => $object['Subject'],\n 'due' => $dueDate,\n 'type' => $object[$playbook->activityField->crm_provider_id],\n ];\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getEvents(string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = $objects = [];\n $user = $this->profile?->user;\n if ($this->profile === null || $user === null) {\n return $data;\n }\n\n $hasWho = \\in_array($objectType, ['lead', 'contact']);\n $playbook = $this->getPlaybook($user);\n $fields = array_merge(\n $this->profile->getFieldsAsArray(parent::OBJECT_EVENT),\n $playbook && $playbook->activityField ? [$playbook->activityField->crm_provider_id] : []\n );\n\n // Query should default to any event starting in the last week and ending up until today owned by the user.\n $query = '\n SELECT ' . implode(',', array_unique($fields)) . '\n FROM Event\n WHERE OwnerId = :ownerId\n AND IsArchived = false\n AND IsAllDayEvent = false\n AND StartDateTime >= LAST_N_DAYS:7\n AND EndDateTime <= TODAY\n AND (';\n\n if ($objectType === 'account') {\n // This covers events tied to a related contact or opportunity too.\n $query .= '\n AccountId = :accountId';\n }\n\n if ($hasWho) {\n $query .= '\n WhoId = :whoId';\n\n // If we are also going to check on a specific opportunity, set that up.\n if ($opportunityId) {\n $query .= ' OR WhatId = :whatId';\n }\n }\n\n $query .= ' ) ORDER BY LastModifiedDate DESC';\n\n try {\n $objects = $this->queryHandler->query($query, [\n 'ownerId' => $this->profile->crm_provider_id,\n 'whoId' => $objectId,\n 'whatId' => $opportunityId,\n 'accountId' => $objectId,\n ]);\n } catch (NoResultsException $e) {\n return $data;\n } finally {\n $this->logger->debug(sprintf('[Salesforce] Found %s tasks for query \"%s\"', count($objects), $query));\n }\n\n foreach ($objects as $object) {\n $dueDate = $object['StartDateTime'] ? Carbon::parse($object['StartDateTime'])->toIso8601String() : null;\n\n $data[] = [\n 'crmId' => $object['Id'],\n 'subject' => $object['Subject'],\n 'due' => $dueDate,\n 'type' => $object[$playbook->activityField->crm_provider_id],\n ];\n }\n\n return $data;\n }\n\n /**\n * Try to find CRM Objects using email address\n *\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchExactlyByEmail(string $email, ?int $userId = null): ?array\n {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $sosl = $queryBuilder->buildMatchByQuery($email, Field::TYPE_EMAIL);\n if ($sosl === null) {\n return null;\n }\n\n try {\n $objects = $this->queryHandler->search($sosl);\n $objects = $this->queryHandler->prioritiseResults(\n $objects,\n $email,\n QueryHandler::PRIORITISE_EMAIL\n );\n\n $data = $this->convertCrmData($objects, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (NoResultsException $e) {\n // Try the account next.\n if ($this->profile->account_fields === null) {\n return null;\n }\n }\n\n return null;\n }\n\n public function getDomain(string $email): ?string\n {\n // SF improved search - strip the domain extension, min domain name length 4\n return $this->getCompanyNameFromEmail(email: $email, minNameLength: 4);\n }\n\n /**\n * Try to find CRM objects using domain name of the email address\n *\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByDomain(string $domain, ?int $userId = null): ?array\n {\n $companyName = $domain;\n\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $sosl = $queryBuilder->buildMatchByDomainQuery($companyName);\n\n try {\n $objects = $this->queryHandler->search($sosl);\n\n $data = $this->convertCrmData($objects, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (NoResultsException) {\n return null;\n }\n }\n\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n // Don't bother looking up numbers that are masked.\n if (str_contains($phone, '**')) {\n return null;\n }\n\n if ($this->isPhoneNumberOfTeamMember($phone)) {\n return null;\n }\n\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $phoneNational = phone_national(null, $phone) ?? '';\n $possiblePhoneFormats = collect([\n preg_replace('/\\D/', '', ltrim($phone, '0+')),\n preg_replace('/\\D/', '', $phoneNational),\n formatDashPhoneNumber($phone),\n $phoneNational,\n ])\n ->filter() // Removes null and empty strings\n ->unique()\n ->values();\n\n foreach ($possiblePhoneFormats as $phone) {\n $sosl = $queryBuilder->buildMatchByQuery($phone, Field::TYPE_PHONE);\n if ($sosl === null) {\n continue;\n }\n\n try {\n $objects = $this->queryHandler->search($sosl);\n $objects = $this->queryHandler->prioritiseResults(\n $objects,\n $phone,\n QueryHandler::PRIORITISE_PHONE\n );\n\n return $this->convertCrmData($objects, $userId);\n } catch (NoResultsException) {\n continue;\n }\n }\n\n return null;\n }\n\n private function isPhoneNumberOfTeamMember(string $phone): bool\n {\n $teamRepository = app(TeamRepository::class);\n $user = $teamRepository->findTeamMemberByPhone($this->team, $phone);\n\n if ($user instanceof User) {\n return true;\n }\n\n return false;\n }\n\n protected function getCacheKey(string $object, ?int $userId = null): ?string\n {\n $key = $this->profile->id . $object;\n $keySuffix = $this->getOwnerKeySuffix($userId);\n\n return $key . $keySuffix;\n }\n\n private function getOwnerKeySuffix(?int $userId = null): string\n {\n return $userId === null ? '' : (string) $userId;\n }\n\n /** Determine the CRM Objects which represent the call activity. */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n if ($this->profile === null) {\n return null;\n }\n\n $cacheKey = $this->getCacheKey($name, $userId);\n\n $result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $sosl = $queryBuilder->buildMatchByQuery($name, 'name');\n if ($sosl === null) {\n return false;\n }\n\n try {\n $objects = $this->queryHandler->search($sosl);\n } catch (NoResultsException $e) {\n return false;\n }\n\n $objects = $this->queryHandler->prioritiseResults(\n $objects,\n $name,\n QueryHandler::PRIORITISE_NAME\n );\n\n $data = $this->convertCrmData($objects, $userId);\n\n return (! empty(array_filter($data))) ? $data : false;\n });\n\n return is_array($result) ? $result : null;\n }\n\n /**\n * @return array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n protected function convertCrmData(QueryIterator $objects, ?int $userId = null): array\n {\n $lead = null;\n $contact = null;\n $opportunity = null;\n $account = null;\n $stage = null;\n $countryCode = null;\n\n if ($objects->count() > 0) {\n $object = $objects->current();\n\n if ($object['attributes']['type'] === 'Lead') {\n $lead = $this->importLead($object);\n\n // Lead might not be imported if the Stage is null for example.\n if ($lead) {\n $countryCode = $lead->country_code;\n $stage = $lead->stage;\n }\n } else {\n if ($object['attributes']['type'] === 'Contact') {\n $contact = $this->importContact($object);\n $account = $contact->account;\n } else {\n $account = $this->importAccount($object);\n }\n\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account) {\n $countryCode = $account->country_code;\n }\n\n try {\n $sfOpportunities = $this->findOpportunities(\n $account?->getCrmProviderId(),\n $contact?->getCrmProviderId(),\n $userId\n );\n\n // Take the first opportunity, which will be ordered as priority based on their settings.\n if (! empty($sfOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($sfOpportunities[0]['crmId']);\n $stage = $opportunity?->stage;\n }\n } catch (Exception) {\n // Nothing to see here.\n }\n }\n }\n\n return [\n $lead,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n /**\n * @inheritdoc\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n if ($stage->type === Stage::TYPE_LEAD) {\n $objectType = 'Lead';\n $objectId = $crmObject->crm_provider_id;\n $objectStageType = 'Status';\n } else {\n $objectType = 'Opportunity';\n $objectId = $crmObject->crm_provider_id;\n $objectStageType = 'StageName';\n }\n\n $headers = [];\n if ($this->config->trigger_assignment_rules === false) {\n // @see: https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/headers_autoassign.htm\n $headers = [\n 'Sforce-Auto-Assign' => 'false',\n ];\n }\n\n $this->updateRecord($objectType, $objectId, [$objectStageType => $stage->name], $headers);\n }\n\n public function parseObjectType(string $objectId): string\n {\n if (Str::startsWith($objectId, '001')) {\n return 'account';\n }\n\n if (Str::startsWith($objectId, '003')) {\n return 'contact';\n }\n\n if (Str::startsWith($objectId, '00Q')) {\n return 'lead';\n }\n\n if (Str::startsWith($objectId, '006')) {\n return 'opportunity';\n }\n\n if (Str::startsWith($objectId, '00U')) {\n return 'event';\n }\n\n if (Str::startsWith($objectId, '00T')) {\n return 'task';\n }\n\n throw new \\InvalidArgumentException('Unsupported Object Type');\n }\n\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, ['profile' => $this->profile]);\n $query = $queryBuilder->buildGetUsersQuery($userToSearch);\n\n try {\n $salesforceUsers = $this->queryHandler->query($query, [\n 'active' => true,\n ]);\n } catch (NoResultsException $e) {\n $this->logger->info('[Salesforce] Sync Profiles. No users found', [\n 'query' => $query,\n 'error' => $e->getMessage(),\n ]);\n\n return null;\n }\n\n $teamRepository = app(TeamRepository::class);\n $customRules = $this->getCustomProfileRules($teamRepository);\n\n foreach ($salesforceUsers as $crmUser) {\n if ($crmUser['Email'] === null) {\n continue;\n }\n\n if (! $this->customProfileValidation($crmUser, $customRules)) {\n continue;\n }\n\n $user = $teamRepository->findActiveTeamMemberByEmail($this->team, $crmUser['Email']);\n\n if (! $user instanceof User) {\n continue;\n }\n\n $edition = $crmUser['UserPreferencesLightningExperiencePreferred']\n ? Profile::EDITION_LIGHTNING\n : Profile::EDITION_CLASSIC;\n\n $profileRepository = app(ProfileRepository::class);\n $profile = $profileRepository->updateOrCreateProfile(\n $user,\n [\n 'crm_configuration_id' => $this->config->getId(),\n 'crm_provider_id' => $crmUser['Id'],\n ],\n [\n 'user_id' => $user->getId(),\n 'edition' => $edition,\n 'has_external_cti' => ! empty($crmUser['CallCenterId']),\n 'crm_profile_id' => $crmUser['ProfileId'],\n ]\n );\n\n if ($userToSearch instanceof User && $userToSearch->getId() === $user->getId()) {\n return $profile;\n }\n }\n\n // Clean up inactive profiles\n try {\n $this->archiveInactiveProfiles();\n } catch (\\Exception $e) {\n $this->logger->warning('[Salesforce] Profile archiving failed', [\n 'teamId' => $this->team->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $url = null;\n\n // For Salesforce it's easy, we just point every object to the apex domain and they handle it.\n switch ($objectType) {\n case 'lead':\n case 'account':\n case 'contact':\n case 'opportunity':\n case 'task':\n case 'event':\n case 'activity':\n\n $url = $this->config->crm_base_url . '/' . $providerId;\n\n break;\n }\n\n return $url;\n }\n\n public function buildTaskSearchFields(): array\n {\n return ['Id', 'WhoId', 'WhatId', 'AccountId'];\n }\n\n public function getTaskByFilterConditions(\n array $fields,\n array $filters,\n bool $bulkSearch = false,\n bool $strictFilters = true\n ): ?array {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $query = $queryBuilder->buildSearchTaskQuery($fields, $filters, $bulkSearch, $strictFilters);\n\n try {\n if (! $bulkSearch) {\n $objects = $this->queryHandler->query($query, $filters);\n if ($objects->count() === 1) {\n return $objects->current();\n }\n }\n\n if ($bulkSearch) {\n $objects = $this->queryHandler->query($query);\n $records = [];\n foreach ($objects as $record) {\n $key = $record[end($fields)];\n $records[$key] = $record;\n }\n\n return $records;\n }\n } catch (\\Exception $e) {\n $this->logger->info('[Salesforce] Failed to execute query', [\n 'query' => $query,\n 'error' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function mapCrmObjects(array $task): array\n {\n $activityData = [];\n\n if (! empty($task['WhoId'])) {\n $type = $this->parseObjectType($task['WhoId']);\n $activityData[$type] = $task['WhoId'];\n }\n if (! empty($task['AccountId'])) {\n $activityData['account'] = $task['AccountId'];\n }\n if (! empty($task['WhatId'])) {\n $activityData['opportunity'] = $task['WhatId'];\n }\n\n return $activityData;\n }\n\n /**\n * Get SF task by Outreach call id.\n */\n public function getTaskByFilter(\n string $activityFieldType,\n array $filters,\n string $operator = '=',\n array $additionalFields = []\n ): ?array {\n $data = [];\n\n try {\n // Default (base) fields.\n $fields = ['Id', 'Subject', 'Description', 'ActivityDate', 'WhoId', 'WhatId', $activityFieldType];\n\n foreach ($additionalFields as $additionalField) {\n $fields[] = $additionalField->crm_provider_id;\n }\n\n $fields = array_unique($fields);\n\n // Find task with the same Outreach id as the call id.\n $query = 'SELECT ' . implode(',', $fields) . '\n FROM Task\n WHERE IsArchived = false AND IsDeleted = false';\n\n foreach ($filters as $key => $value) {\n $key = preg_quote($key, '/');\n $key = str_replace(['\\'', '\"'], '', $key);\n // Prepare the substitution.\n $strKey = \":$key\";\n\n $query .= \" AND $key $operator $strKey\";\n }\n\n $query .= ' ORDER BY LastModifiedDate DESC LIMIT 1';\n\n $objects = $this->queryHandler->query($query, $filters);\n\n // There should be only one task related to this call if any.\n if ($objects->count() === 1) {\n $object = $objects->current();\n\n $dueDate = $object['ActivityDate'] ? Carbon::parse($object['ActivityDate'])->toIso8601String() : null;\n\n $data = array_merge($object, [\n 'crmId' => $object['Id'],\n 'subject' => $object['Subject'],\n 'summary' => $object['Description'],\n 'due' => $dueDate,\n 'Type' => $object[$activityFieldType],\n ]);\n }\n } catch (NoResultsException $e) {\n // Filters don't match any records.\n } catch (ServiceUnavailableException $serviceUnavailableException) {\n // Service cannot be queried. We should probably log this.\n }\n\n return $data;\n }\n\n /**\n * Get Salesforce fields including datetime fields\n *\n * @param $objectType\n */\n private function getAllFieldsAsArray($objectType): array\n {\n $basicFields = [];\n // Not all users have access to all object fields.\n if ($this->profile->{$objectType . '_fields'}) {\n $basicFields = explode(',', $this->profile->{$objectType . '_fields'});\n }\n\n $extraFields = [\n 'CreatedDate',\n 'LastModifiedDate',\n 'IsDeleted',\n ];\n\n if ($objectType === self::OBJECT_OPPORTUNITY\n && $this->config->opportunity_value_field_id\n && ! in_array($this->config->opportunityValueField->crm_provider_id, $basicFields)\n ) {\n $extraFields[] = $this->config->opportunityValueField->crm_provider_id;\n }\n\n return array_unique(array_merge($basicFields, $extraFields));\n }\n\n /**\n * Generate transcription for activity description.\n */\n private function generateTranscription(Activity $activity): string\n {\n if (! ($this->config->store_transcript)) {\n // If sending transcription to activity toggle is disabled\n return '';\n }\n\n return $this->transcriptionService\n ->findTranscriptionByActivity($activity)\n ->map(static function (array $transcriptionSegment): string {\n return $transcriptionSegment['formattedStartsAt'] . ' | ' . $transcriptionSegment['transcript'];\n })\n ->implode(PHP_EOL);\n }\n\n /**\n * Find related Salesforce event based on activity data\n *\n * @return array<string>\n */\n public function fetchRelatedActivity(Activity $activity): array\n {\n $this->logger->info('[Salesforce] Searching for related activity', [\n 'activityId' => $activity->getUuid(),\n 'ownerId' => $this->profile?->crm_provider_id,\n ]);\n\n $sfEvent = $this->fetchRelatedEvent($activity);\n if (empty($sfEvent)) {\n $this->logger->info('[Salesforce] No related activity found', [\n 'activityId' => $activity->getUuid(),\n 'ownerId' => $this->profile?->crm_provider_id,\n 'account' => $activity->hasAccount()\n ? $activity->getAccount()->getCrmProviderId()\n : null,\n ]);\n\n return [];\n }\n\n return $sfEvent;\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n if ($activity->isTypeConference() === false) {\n return null;\n }\n\n if ($activity->hasActualStartTime() === false && $activity->hasScheduledStartTime() === false) {\n return null;\n }\n\n if (! $activity->hasProspect()) {\n $this->logger->info('[Salesforce] Skip look up, Activity not linked to Lead, Contact or Account', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n $playbook = $this->getPlaybook($activity->getUser());\n if ($playbook !== null && $playbook->getActivityType() === Playbook::ACTIVITY_TYPE_TASK) {\n $this->logger->info('[Salesforce] Skip auto-sync for task-based playbook', [\n 'activityUuid' => $activity->getUuid(),\n 'playbookId' => $playbook->getId(),\n 'playbookType' => $playbook->getActivityType(),\n ]);\n\n return null;\n }\n\n try {\n $sfEvent = $this->fetchRelatedActivity($activity);\n if (empty($sfEvent)) {\n return null;\n }\n\n [$activityField, $activityType] = $this->resolveActivityTypeFromEvent($activity, $sfEvent);\n\n $this->logger->info('[Salesforce] Found related activity', [\n 'activityId' => $activity->getUuid(),\n 'sfEvent' => $sfEvent['Id'],\n 'activityFieldName' => $activityField,\n 'crmActivityType' => ($activityField !== null && isset($sfEvent[$activityField]))\n ? $sfEvent[$activityField]\n : null,\n 'activityType' => $activityType,\n ]);\n\n $userId = $this->findRelatedActivityUserId($activity, $sfEvent);\n\n if ($activity->getUserId() !== $userId) {\n $this->logger->info('[Salesforce] Updating meeting owner', [\n 'activityId' => $activity->getUuid(),\n 'oldUserId' => $activity->getUserId(),\n 'newUserId' => $userId,\n ]);\n }\n\n $this->updateSfEventDescription($activity, $sfEvent);\n\n $activity->update([\n 'user_id' => $userId,\n 'crm_provider_id' => $sfEvent['Id'],\n 'playbook_category_id' => $activityType->id ?? $activity->getCategory()?->getId(),\n ]);\n\n $this->logger->info('[Salesforce] Activity updated', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return $activity;\n } catch (\\Exception $exception) {\n \\Sentry::captureException($exception);\n\n throw $exception;\n }\n }\n\n /**\n * @param array<string, mixed> $sfEvent\n *\n * @return array{0: string|null, 1: mixed}\n */\n private function resolveActivityTypeFromEvent(Activity $activity, array $sfEvent): array\n {\n $activityField = $this->getActivityFieldName($activity);\n $activityType = null;\n\n if ($activityField !== null && ! empty($sfEvent[$activityField])) {\n $playbook = $this->getPlaybook($activity->getUser());\n $activityType = $this->getPlaybookCategory($playbook, strval($sfEvent[$activityField]));\n }\n\n return [$activityField, $activityType];\n }\n\n /**\n * @param array<string> $sfEvent\n */\n private function findRelatedActivityUserId(Activity $activity, array $sfEvent): int\n {\n $userId = $activity->getUserId();\n\n if (empty($sfEvent['OwnerId']) === false) {\n $profile = $this\n ->config\n ->profiles()\n ->where('crm_provider_id', $sfEvent['OwnerId'])\n ->get()\n ->filter(static function (Profile $profile) use ($activity): bool {\n if (! $activity->isTypeConference()) {\n return ! empty($profile->user) ? $profile->user->isStatusActive() : false;\n }\n\n $participants = $activity->getParticipants();\n\n return ! empty($profile->user)\n ? $profile->user->isStatusActive()\n && $profile->user->hasPermission(PermissionEnum::RECORD_MEETING)\n && $participants->contains('user_id', $profile->user_id)\n : false;\n })\n ->first();\n\n if ($profile) {\n $userId = $profile->user_id;\n }\n }\n\n return $userId;\n }\n\n /**\n * @param array<string, mixed> $sfEvent\n */\n private function updateSfEventDescription(Activity $activity, array $sfEvent): void\n {\n try {\n if (str_contains($sfEvent['Description'], $activity->id_string)) {\n return;\n }\n\n $payload = [\n 'Description' => $sfEvent['Description']\n . PHP_EOL\n . PHP_EOL\n . (new DecorateActivity())->generateDescription($activity),\n ];\n\n $this->logger->info('[Salesforce] Update record', [\n 'activityId' => $activity->getUuid(),\n 'sfEvent' => $sfEvent['Id'],\n 'payload' => $payload,\n ]);\n\n $payload = array_merge(\n $payload,\n $this->payloadBuilder->fetchCustomFieldData($activity, Field::OBJECT_EVENT)\n );\n\n $this->updateRecord('Event', $sfEvent['Id'], $payload);\n } catch (\\Exception) {\n $this->logger->error('[Salesforce] Failed to update record', [\n 'activityUuid' => $activity->getUuid(),\n 'sfEvent' => $sfEvent['Id'],\n ]);\n }\n }\n\n /**\n * Returns the most recently modified Event within time range (if any).\n *\n * @return array|null An Event record from Salesforce.\n */\n private function fetchRelatedEvent(Activity $activity): ?array\n {\n $ownerId = $this->profile?->crm_provider_id;\n if ($ownerId === null) {\n return [];\n }\n\n /** @var ?Carbon $from */\n /** @var ?Carbon $to */\n [$from, $to] = $this->getFromToDates($activity);\n\n try {\n $whoId = null;\n $hasWho = $activity->lead_id || $activity->contact_id;\n if ($hasWho) {\n $whoId = $activity->hasLead()\n ? $activity->getLead()->crm_provider_id\n : $activity->getContact()->crm_provider_id;\n }\n\n if ($hasWho === false && $activity->account_id === null) {\n return null;\n }\n\n $query = $this->buildFetchRelatedEventQuery($activity);\n\n $objects = $this->queryHandler->query($query, [\n 'ownerId' => $ownerId,\n 'whoId' => $whoId,\n 'whatId' => $activity->hasOpportunity() ? $activity->getOpportunity()->crm_provider_id : null,\n 'accountId' => $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null,\n 'from' => $from?->format('Y-m-d\\TH:i:s\\Z'),\n 'to' => $to?->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($objects as $object) {\n return $object;\n }\n } catch (NoResultsException $e) {\n return [];\n }\n\n return [];\n }\n\n private function getFromToDates(Activity $activity): array\n {\n $from = null;\n $to = null;\n\n /** @var ?CalendarEvent $calendarEvent */\n $calendarEvent = $activity->calendarEvent()->first();\n if ($calendarEvent !== null) {\n $from = $calendarEvent->getStartTime();\n $to = $calendarEvent->getEndTime();\n }\n\n // For non-calendar imported activities\n // Also double check if calendar event dates could be null?\n // If null use what we've got so far\n if ($from === null || $to === null) {\n $from = $activity->hasScheduledStartTime()\n ? $activity->getScheduledStartTime()\n : $activity->getActualStartTime();\n $to = $activity->hasScheduledEndTime()\n ? $activity->getScheduledEndTime()->addMinutes(15)\n : $activity->getActualEndTime();\n }\n\n return [$from, $to];\n }\n\n /**\n * Determines the appropriate activity field name for querying Salesforce events.\n *\n * This method follows a hierarchy to determine the field name:\n * 1. Uses the playbook's activity field if it exists and is in the profile's accessible fields\n * 2. Falls back to the default activity field if the profile has no event fields configured\n * 3. Returns null if no suitable field is found\n *\n * @param Activity $activity The activity to determine the field for\n *\n * @return string|null The field name to use in queries, or null if none is available\n */\n private function getActivityFieldName(Activity $activity): ?string\n {\n if ($this->profile === null) {\n $this->logger->warning('[Salesforce] Cannot determine activity field - profile not found', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n $profileEventFields = $this->profile->getFieldsAsArray('event');\n\n if (empty($profileEventFields)) {\n $defaultActivityField = $this->getDefaultActivityField(Field::OBJECT_EVENT);\n $defaultFieldName = $defaultActivityField?->getAttribute('crm_provider_id');\n // Profile not yet synced — fall back to the default activity field.\n // There is a small chance that the profile won't have Default Activity Type field access\n // in which case the query will fail.\n // This is however an edge case and should be reviewed for profile sync issues.\n Sentry::withScope(function (\\Sentry\\State\\Scope $scope) use ($defaultFieldName): void {\n $scope->setContext('details', [\n 'profileId' => $this->profile->id,\n 'defaultField' => $defaultFieldName,\n ]);\n Sentry::captureMessage(\n '[Salesforce] Profile event fields empty, falling back to default activity field.',\n \\Sentry\\Severity::warning()\n );\n });\n\n return $defaultFieldName;\n }\n\n $playbook = $this->getPlaybook($activity->getUser());\n\n if (! is_null($playbook) && ! is_null($playbook->getActivityField())) {\n $playbookFieldName = $playbook->getActivityField()->getAttribute('crm_provider_id');\n\n if (in_array($playbookFieldName, $profileEventFields, true)) {\n return $playbookFieldName;\n }\n\n $this->logger->warning('[Salesforce] Playbook activity field not found in profile fields', [\n 'activityId' => $activity->getUuid(),\n 'playbookField' => $playbookFieldName,\n 'profileId' => $this->profile->id,\n ]);\n }\n\n return null;\n }\n\n private function buildFetchRelatedEventQuery(Activity $activity): string\n {\n $hasWho = $activity->lead_id || $activity->contact_id;\n\n $activityFieldName = $this->getActivityFieldName($activity);\n $fields = array_filter(['Id', 'Description', 'OwnerId', $activityFieldName]);\n\n $ownerCondition = '(OwnerId = :ownerId OR CreatedById = :ownerId)';\n\n $query = '\n SELECT ' . implode(',', $fields) . '\n FROM Event\n WHERE ' . $ownerCondition . '\n AND IsArchived = false\n AND IsAllDayEvent = false\n AND StartDateTime >= :from\n AND EndDateTime <= :to\n AND (';\n\n $operator = '';\n if ($activity->account_id) {\n // This covers events tied to a related contact or opportunity too.\n $query .= 'AccountId = :accountId';\n\n $operator = ' OR ';\n }\n\n if ($hasWho) {\n $query .= $operator . 'WhoId = :whoId';\n\n // If we are also going to check on a specific opportunity, set that up.\n if ($activity->opportunity_id) {\n $query .= ' OR WhatId = :whatId';\n }\n }\n\n $query .= ') ORDER BY LastModifiedDate DESC';\n\n return $query;\n }\n\n public function fetchProspect(array $task): array\n {\n $lead = $account = $opportunity = $contact = $stage = $countryCode = null;\n $externalId = $task['WhoId'] ?? null;\n\n // Lead or Contact\n if ($externalId) {\n try {\n [$lead, $account, $opportunity, $contact, $stage, $countryCode] = $this->parseRecords($externalId);\n } catch (\\InvalidArgumentException $exception) {\n // Invalid object type.\n }\n }\n\n // If we happen to know the opportunity or account from the Task, figure that out.\n if (empty($task['WhatId']) === false) {\n // WhatId could be either Account ID or Opportunity ID.\n // If WhatId is Opportunity ID, get the opportunity and stage from the CRM.\n try {\n [, $account, $opportunity, , $stage, ] = $this->parseRecords($task['WhatId']);\n } catch (\\InvalidArgumentException $exception) {\n // Invalid object type.\n }\n }\n\n return [$lead, $account, $opportunity, $contact, $stage, $countryCode];\n }\n\n /**\n * Save activity transcription summary as note\n */\n public function saveTranscriptionSummaryAsNote(\n ActivityContract $activity,\n string $title,\n string $body,\n ?string $objectId,\n ?NoteObject $noteObject = null,\n ): ?string {\n return $this->saveNote($title, $body, (string) $objectId);\n }\n\n public function getObjectByFilterConditions(string $objectType, array $fields, array $filters): ?array\n {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $query = $queryBuilder->buildObjectSearchQuery($objectType, $fields, $filters);\n\n try {\n $objects = $this->queryHandler->query($query, $filters);\n if ($objects->count() === 1) {\n return $objects->current();\n }\n } catch (\\Exception $e) {\n $this->logger->info('[Salesforce] Failed to execute query', [\n 'query' => $query,\n 'error' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n private function getCustomProfileRules(TeamRepository $teamRepository): array\n {\n $teamSettings = $teamRepository->getTeamSetting($this->team, 'custom_profile_validation');\n\n if ($teamSettings instanceof TeamSettings && $teamSettings->getValueType() === 'array') {\n $customRules = json_decode($teamSettings->getValue(), true);\n if (is_array($customRules)) {\n return $customRules;\n }\n }\n\n return [];\n }\n\n private function customProfileValidation(array $crmUser, array $customRules): bool\n {\n foreach ($customRules as $customRule) {\n if ($crmUser[$customRule['field']] !== $customRule['value']) {\n return false;\n }\n }\n\n return true;\n }\n\n /**\n * When syncing Contact / Lead / Account / Opportunity / Stage crm entities,\n * validate and restore locally trashed objects,\n * before updating them. Objects are identified by CrmProviderId\n */\n private function restoreAnyTrashedEntity(HasMany $targetEntity, string $crmProviderId): void\n {\n $recordExists = $targetEntity->withTrashed()->where(['crm_provider_id' => $crmProviderId])->first();\n if ($recordExists && $recordExists->trashed()) {\n $recordExists->restore();\n }\n }\n\n #[\\Override] public function supportsNotes(): bool\n {\n return true;\n }\n\n private function getOwnerProfile(?string $ownerId): ?Profile\n {\n if ($ownerId === null) {\n return null;\n }\n\n return $this->config->profiles()\n ->where('crm_provider_id', $ownerId)\n ->first();\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
6958197611389412680
|
-7851939513083163707
|
typing_pause
|
accessibility
|
NULL
|
Diff
Changelist:
Changelist:
Changes
Show Diff
Rol Diff
Changelist:
Changelist:
Changes
Show Diff
Rollback...
Refresh
Group By
Expand All
Collapse All
1 modified
J
Commit Message
Commit Message History
Git
Author:
Author:
Amend commit
Sign-off commit
Create extra commit with file movements
Commit Checks
Update copyright
Reformat code
Rearrange code
Optimize imports
Cleanup
Choose profile
Check malicious dependencies
Run external formatter
Configure
Perform SonarQube for IDE analysis
Find unused view files
Run Git hooks
Advanced Commit Checks
Analyze code
Choose profile
Check TODO
Configure
After Commit
Upload files to:
Upload files to:
<None>
Always use selected server or group of servers
Previous Difference
Next Difference
Jump to Source
Compare Previous File
Compare Next File
Go to Changed File…
Side-by-side viewer
Do not ignore
Highlight words
Collapse Unchanged Fragments
Synchronize Scrolling
Disable Editing
Settings
Help
4 differences
1aee7aad
<?php
namespace Jiminny\Services\Crm\Salesforce;
use Carbon\Carbon;
use Exception;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Events\Dispatcher;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Jiminny\Component\Country\CountriesMap;
use Jiminny\Contracts\Acl\PermissionEnum;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Contracts\Services\Crm\FetchRelatedActivityInterface;
use Jiminny\Contracts\Services\Crm\ImportsBusinessProcessesInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\SalesforceBatchSyncInterface;
use Jiminny\Contracts\Services\Crm\Provider\SalesforceInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\RemoteNoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SearchTaskInterface;
use Jiminny\Contracts\Services\Crm\SendSummaryToCrmInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SupportsObjectTypeParseInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmProfileRecordTypesInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Enums\CrmObject;
use Jiminny\Events\Activities\Crm\LeadConverted;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpBadRequestException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Exceptions\NoResultsException;
use Jiminny\Exceptions\ServiceUnavailableException;
use Jiminny\Jobs\Crm\NoteObject;
use Jiminny\Jobs\Crm\MatchActivitiesToNewOpportunity;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Calendar\CalendarEvent;
use Jiminny\Models\Contact;
use Jiminny\Models\Contracts\ActivityContract;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Models\Crm\ContactRole;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Crm\RecordType;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Playbook;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\TeamSettings;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\ContactRoleRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\Crm\RecordTypeFieldValuesRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Helpers\ArrayIterator;
use Jiminny\Services\Crm\MatchDomainByEmailInterface;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Services\Crm\ResolveCompanyNameByEmailTrait;
use Jiminny\Services\Crm\Salesforce\Fields\FieldHelper;
use Jiminny\Services\Crm\Salesforce\Fields\FieldTypeConverter;
use Jiminny\Services\Crm\Salesforce\Fields\ValueNormalizer;
use Jiminny\Services\Crm\Salesforce\ServiceTraits\FollowupActivityTrait;
use Jiminny\Services\Crm\Salesforce\ServiceTraits\LogActivityTrait;
use Jiminny\Services\Crm\Salesforce\ServiceTraits\RecordManipulationsTrait;
use Jiminny\Services\Crm\Salesforce\ServiceTraits\SyncFieldsTrait;
use Jiminny\Utils\CurrencyFormatter;
use Jiminny\Utils\StringUtil;
use Ramsey\Uuid\Uuid;
use Sentry\Laravel\Facade as Sentry;
class Service extends BaseService implements
SalesforceInterface,
SalesforceBatchSyncInterface,
SyncCrmEntitiesInterface,
SyncCrmProfileRecordTypesInterface,
ImportsBusinessProcessesInterface,
RemoteEntityManipulationInterface,
FetchRelatedActivityInterface,
SendSummaryToCrmInterface,
MatchDomainByEmailInterface,
SearchTaskInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
SupportsObjectTypeParseInterface,
RemoteNoteEntityManipulationInterface,
VerifyTaskExistsInterface
{
use ResolveCompanyNameByEmailTrait;
use SyncFieldsTrait;
use DeleteObjectsTrait;
use RecordManipulationsTrait;
use ServiceTraits\BatchSyncTrait;
use FollowupActivityTrait;
use LogActivityTrait;
/**
* Note Body Limit for the Old Note-Taking Tool
*
* @var int
*/
private const int CLASSIC_NOTE_MAX_LENGTH = 32000;
/**
* Note Content Limit for the New Notes
*
* @var int
*/
private const int ENHANCED_NOTE_MAX_LENGTH = 50000000;
private const string INSTALLED_PACKAGE_ID = '033Tw0000007bKbIAI';
private const int CACHE_TTL = 600;
private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day - 86400
/**
* @var Client
*/
protected $client;
protected PayloadBuilder $payloadBuilder;
protected QueryHandler $queryHandler;
private OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
public function __construct(
Client $client,
PayloadBuilder $payloadBuilder,
protected Dispatcher $eventDispatcher,
private readonly CountriesMap $countriesMap,
private readonly ProspectPhotoPathService $prospectPhotoPathService,
) {
parent::__construct();
$this->client = $client;
$this->payloadBuilder = $payloadBuilder;
$this->queryHandler = app(QueryHandler::class, [
'client' => $this->client,
'logger' => $this->logger,
]);
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
}
public function getDisplayName(): string
{
return 'Salesforce';
}
public function getJobDelay(): int
{
return 1;
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
return $user->getSocialAccount(SocialAccount::PROVIDER_SALESFORCE);
}
public function verifyTaskExists(Activity $activity): bool
{
$crmProviderId = $activity->getCrmProviderId();
$cacheKey = "crm_task_exists:{$this->config->getId()}:$crmProviderId";
return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($activity, $crmProviderId) {
$playbook = $this->getPlaybookFromActivity($activity);
if ($playbook === null) {
$this->logger->warning('[Salesforce] Cannot verify task - no playbook found', [
'activity' => $activity->getId(),
'crm_provider_id' => $crmProviderId,
]);
return false;
}
$objectType = $playbook->getActivityType() === Playbook::ACTIVITY_TYPE_EVENT ? 'Event' : 'Task';
try {
$record = $this->getRecord($objectType, $crmProviderId, ['Id', 'IsDeleted']);
return ! empty($record) && ($record['IsDeleted'] ?? false) === false;
} catch (HttpNotFoundException|HttpBadRequestException) {
$this->logger->info('[Salesforce] Activity record not found during verification', [
'activity' => $activity->getId(),
'object_type' => $objectType,
'crm_provider_id' => $crmProviderId,
'config_id' => $this->config->getId(),
]);
return false;
}
});
}
public function query(string $queryToRun, array $parameters = []): QueryIterator
{
// Due to poorly designed external calls, this method cannot be entirely removed
return $this->queryHandler->query($queryToRun, $parameters);
}
/*=========== Organization Information ===============*/
/**
* Get a list of all the API Versions for the instance.
*
* @throws CrmException
*
* @return mixed
*
*/
public function getApiVersions()
{
$url = $this->config->crm_base_url . '/services/data';
$response = $this->client->get($url);
return json_decode($response->getBody(), true);
}
/**
* Gets the valid recordTypes for a given Salesforce Object via the describe API.
*/
private function getRecordTypes(string $crmObject): array
{
$url = $this->client->getObjectsUrl() . $crmObject . '/describe';
$response = $this->client->get($url);
$jsonResponse = json_decode($response->getBody(), true);
$fields = [];
foreach ($jsonResponse['recordTypeInfos'] as $row) {
$fields[] = ['recordTypeId' => $row['recordTypeId'], 'default' => $row['defaultRecordTypeMapping']];
}
return $fields;
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return ValueNormalizer::normalize($fieldType, $fieldValue, $internal);
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
$defaultFields = ($activityType === Playbook::ACTIVITY_TYPE_TASK)
? FieldDefinitions::defaultTaskFields()
: FieldDefinitions::defaultEventFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
// Setup the activity field as the default Type.
/** @var Field $activityField */
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'Type',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK, Playbook::ACTIVITY_TYPE_EVENT];
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldFilter = ($activityType === Playbook::ACTIVITY_TYPE_TASK)
? FieldDefinitions::taskFollowupFieldsFilter()
: FieldDefinitions::eventFollowupFieldsFilter();
foreach ($fieldFilter as $eachFilter) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $eachFilter);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
/**
* This one is now called only when ImportActivityTypes is triggered or SyncFieldMetadata executed manually
* Regular sync now uses SharedSyncFieldsTrait -> syncSingleObjectType
* Needs to be replaced later on
*/
public function syncField(Field $field): void
{
try {
if (FieldHelper::isCustomField($field)) {
$query = '
SELECT
Id, Metadata, TableEnumOrId
FROM
CustomField
WHERE
DeveloperName = :fieldName
AND
TableEnumOrId = :fieldType
AND
NamespacePrefix = :namespacePrefix';
// We need to constrain the field lookup to the object, in case it's used in multiple places.
$objectType = \in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true)
? 'activity'
: $field->object_type;
$sfFields = $this->queryHandler->metadata($query, [
'fieldName' => substr($field->crm_provider_id, 0, -\strlen('__c')),
'fieldType' => ucfirst($objectType),
// This is used to ensure we only consider the field within the org, not installed packages.
'namespacePrefix' => 'null',
]);
// There is always 1 result at this point.
$sfField = $sfFields->current();
// Sync field metadata.
$metadata = $sfField['Metadata'];
$field->description = mb_strimwidth($metadata['description'] ?? '', 0, 191);
$field->label = mb_strimwidth($metadata['label'] ?? '', 0, Field::LABEL_MAX_LENGTH);
$field->type = $this->convertFieldType($metadata['type'], $field->getEntityName());
$field->is_mandatory = ($metadata['required'] === true);
$field->length = $metadata['length'];
$field->default_value = mb_strimwidth(trim($metadata['defaultValue'] ?? '', '"'), 0, 191);
$field->save();
} else {
$query = '
SELECT
Id, DataType, DeveloperName, Label, Length, Description
FROM
FieldDefinition
WHERE
DurableId = :entityName';
$entityName = $field->getEntityName();
$sfFields = $this->queryHandler->metadata($query, [
'entityName' => $entityName,
]);
// There is always 1 result at this point.
$sfField = $sfFields->current();
$convertedType = $this->convertFieldType($sfField['DataType'], $entityName);
$label = mb_strimwidth($sfField['Label'], 0, Field::LABEL_MAX_LENGTH);
if ($field->isBusinessType()) {
$label = 'Opportunity Type';
}
$field->description = mb_strimwidth($sfField['Description'], 0, Field::DESCRIPTION_MAX_LENGTH);
$field->label = $label;
$field->type = $convertedType;
$field->length = $sfField['Length'];
$field->save();
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
}
private function convertFieldType(string $from, ?string $entityName = null): string
{
$converter = new FieldTypeConverter();
return $converter->convert($from, $entityName);
}
/**
* @inheritdoc
*/
public function importPicklistValues(Field $field): array
{
$values = [];
$fieldValues = [];
try {
if (FieldHelper::isCustomField($field)) {
$query = '
SELECT
Id, Metadata, TableEnumOrId
FROM
CustomField
WHERE
DeveloperName = :fieldName
AND
TableEnumOrId = :fieldType
AND
NamespacePrefix = :namespacePrefix';
// We need to constrain the field lookup to the object, in case it's used in multiple places.
$objectType = \in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true) ?
'activity' : $field->object_type;
$sfFields = $this->queryHandler->metadata($query, [
'fieldName' => substr($field->crm_provider_id, 0, -\strlen('__c')),
'fieldType' => ucfirst($objectType),
// This is used to ensure we only consider the field within the org, not installed packages.
'namespacePrefix' => 'null',
]);
// There is always 1 result at this point.
$sfField = $sfFields->current();
$valueSet = $sfField['Metadata']['valueSet'];
if ($valueSet['valueSetName'] === null) {
// Local picklist values can be obtained easily.
$picklistValues = $valueSet['valueSetDefinition']['value'];
} else {
// But for some fields, we just get the Global Value Picklist pointer so need to do more work.
$picklistValues = $this->importGlobalValuePicklistValues($valueSet['valueSetName']);
}
// Import all active values.
foreach ($picklistValues as $i => $sfFieldValue) {
// Setup default value.
if ($sfFieldValue['default']) {
$field->update(['default_value' => $sfFieldValue['valueName']]);
}
// This comes through as null if active (lol).
if ($sfFieldValue['isActive'] !== false) {
$values[] = [
'value' => $sfFieldValue['valueName'],
'label' => $sfFieldValue['valueName'],
'sequence' => $i,
'is_default' => $sfFieldValue['default'],
];
}
}
} else {
$objectFields = $this->getObjectFields($field->object_type);
$fieldId = $field->crm_provider_id;
// Only work with our field of interest.
$objectField = array_filter($objectFields, function ($item) use ($fieldId) {
return $item['name'] === $fieldId;
});
$objectField = array_shift($objectField);
if (empty($objectField['picklistValues']) === false) {
foreach ($objectField['picklistValues'] as $i => $sfFieldValue) {
// Skip inactive values.
if ($sfFieldValue['active'] === false) {
continue;
}
// Setup default value.
if ($sfFieldValue['defaultValue']) {
$field->update(['default_value' => $sfFieldValue['value']]);
}
$values[] = [
'value' => $sfFieldValue['value'],
'label' => $sfFieldValue['label'],
'sequence' => $i,
'is_default' => $sfFieldValue['defaultValue'],
];
}
}
}
$fieldsToPurge = $field->values()->get()->pluck('value')->toArray();
foreach ($values as $value) {
$value['value'] = substr($value['value'] ?? '', 0, 255);
$fieldValues[] = $field->values()->updateOrCreate([
'value' => $value['value'],
], $value);
// Remove this value from the ones we are going to purge.
if (($key = array_search($value['value'], $fieldsToPurge, true)) !== false) {
unset($fieldsToPurge[$key]);
}
}
// Delete the old values that are no longer used.
// Get IDs of the values to be deleted
$valuesToDelete = $field->values()->whereIn('value', $fieldsToPurge);
$valuesToDeleteIds = $valuesToDelete->pluck('id');
if (! $valuesToDeleteIds->isEmpty()) {
$recordTypeFieldValuesRepository = app(RecordTypeFieldValuesRepository::class);
$recordTypeFieldValuesRepository->deleteForCrmFieldValueIds($valuesToDeleteIds->toArray());
// Now safely delete from crm_field_values
$valuesToDelete->delete();
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
return $fieldValues;
}
/**
* Gets values from Global Value Picklists.
*/
private function importGlobalValuePicklistValues(string $picklistName): array
{
$query = '
SELECT
Metadata
FROM
GlobalValueSet
WHERE
DeveloperName = :picklistName
LIMIT 1';
try {
$sfValues = $this->queryHandler->metadata($query, [
'picklistName' => $picklistName,
]);
// There is always 1 result at this point.
$sfValue = $sfValues->current();
return $sfValue['Metadata']['customValue'];
} catch (NoResultsException $noResultsException) {
// Nothing returned.
return [];
}
}
/**
* @inheritdoc
*/
public function syncProfileRecordTypes(): void
{
$objectTypes = [
'lead',
'account',
'contact',
'opportunity',
'task',
'event',
];
foreach ($objectTypes as $objectType) {
try {
$crmRecordTypes = $this->getRecordTypes(ucfirst($objectType));
foreach ($crmRecordTypes as $crmRecordType) {
// If the record type is default and not the Master type, set this.
if ($crmRecordType['default'] && $crmRecordType['recordTypeId'] !== '012000000000000AAA') {
$recordType = $this->config->recordTypes()
->where('crm_provider_id', $crmRecordType['recordTypeId'])
->first();
if ($recordType) {
$this->profile->{$objectType . '_record_type_id'} = $recordType->id;
}
}
}
} catch (HttpNotFoundException $exception) {
Log::error('No access to ' . $objectType . ' object, skipping...');
// XXX: should we log this fact somewhere?
continue;
}
}
if ($this->profile->isDirty()) {
$this->profile->save();
}
}
/**
* Gets business processes.
*/
public function importBusinessProcesses(): void
{
$query = '
SELECT
Id, IsActive, Name, TableEnumOrId
FROM
BusinessProcess
WHERE
TableEnumOrId IN (\'Lead\',\'Opportunity\')';
try {
$sfProcesses = $this->queryHandler->query($query);
// Upsert all processes for the team.
foreach ($sfProcesses as $sfProcess) {
/** @var BusinessProcess $businessProcess */
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => $sfProcess['Id'],
], [
'team_id' => $this->team->id,
'name' => $sfProcess['Name'],
'type' => $sfProcess['TableEnumOrId'] === 'Lead' ? 'lead' : 'opportunity',
'is_selectable' => $sfProcess['IsActive'],
]);
$this->importBusinessProcessStages($businessProcess);
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
}
/**
* Gets business process stages.
*/
private function importBusinessProcessStages(BusinessProcess $businessProcess): void
{
$query = '
SELECT
Metadata
FROM
BusinessProcess
WHERE
Id = :processId';
try {
$stages = [];
$sfProcessStages = $this->queryHandler->metadata($query, [
'processId' => $businessProcess->crm_provider_id,
]);
// There is always 1 result at this point.
$sfProcessStage = $sfProcessStages->current();
// Upsert all processes for the team.
foreach ($sfProcessStage['Metadata']['values'] as $sfProcessStage) {
$sanitizedName = urldecode($sfProcessStage['valueName']); // Must decode: "%2C" becomes "," etc.
$stage = $businessProcess->crm->stages()
// This MUST match on label because this API doesn't use API Name.
->where('label', $sanitizedName)
->where('type', $businessProcess->type)
->where('is_selectable', 1)
->first();
if ($stage) {
$stages[] = $stage->id;
}
}
$businessProcess->stages()->sync($stages);
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
}
/**
* Gets record types.
*/
public function importRecordTypes(): void
{
$query = '
SELECT
Id, IsActive, Name, BusinessProcessId, SobjectType
FROM
RecordType';
try {
$sfRecordTypes = $this->queryHandler->query($query);
// Upsert all record types for the process.
foreach ($sfRecordTypes as $sfRecordType) {
$businessProcess = null;
if ($sfRecordType['BusinessProcessId']) {
$businessProcess = $this->config->businessProcesses()
->where('crm_provider_id', $sfRecordType['BusinessProcessId'])
->first();
}
/** @var RecordType $recordType */
$recordType = $this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => $sfRecordType['Id'],
], [
'team_id' => $this->team->id,
'type' => mb_strtolower($sfRecordType['SobjectType']),
'name' => $sfRecordType['Name'],
'is_selectable' => $sfRecordType['IsActive'],
'business_process_id' => $businessProcess->id ?? null,
]);
$this->importRecordTypeFieldValues($recordType);
}
} catch (NoResultsException $noResultsException) {
// Do nothing.
}
}
/**
* Import record type - field value mappings. This only works for standard fields.
*/
private function importRecordTypeFieldValues(RecordType $recordType): void
{
try {
$query = '
SELECT
Metadata
FROM
RecordType
WHERE
Id = :recordTypeId';
$sfFields = $this->queryHandler->metadata($query, [
'recordTypeId' => $recordType->crm_provider_id,
]);
// There is always 1 result at this point.
$sfField = $sfFields->current();
// Sync field metadata.
$picklists = $sfField['Metadata']['picklistValues'];
foreach ($picklists as $picklist) {
$field = $this->config->fields()->where([
'type' => Field::TYPE_PICKLIST,
'object_type' => $recordType->type,
'crm_provider_id' => $picklist['picklist'],
])->first();
if ($field) {
$fieldValues = [];
foreach ($picklist['values'] as $value) {
// Must decode: "%2C" becomes "," etc.
$fieldValue = $field->values()
->where('value', urldecode($value['valueName']))
->first();
if ($fieldValue) {
$fieldValues[] = $fieldValue->id;
}
}
$recordType->fieldValues()->sync($fieldValues);
}
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
}
/**
* @inheritdoc
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$params = [];
$missingStage = null;
if ($types === null) {
$types = [Stage::TYPE_LEAD, Stage::TYPE_OPPORTUNITY];
}
foreach ($types as $type) {
if ($type === Stage::TYPE_LEAD) {
$query = '
SELECT
Id, ApiName, MasterLabel, SortOrder
FROM
LeadStatus';
} else {
$query = '
SELECT
Id, ApiName, MasterLabel, IsActive, SortOrder, DefaultProbability
FROM
OpportunityStage';
}
if ($missingStageName) {
$escapedStageName = ValueNormalizer::replaceQueryWithStringLiterals($missingStageName);
$query .= ' WHERE ApiName = :stageName';
$params = [
'stageName' => $escapedStageName,
];
}
try {
$sfStages = $this->queryHandler->query($query, $params);
} catch (NoResultsException $exception) {
$sfStages = [];
}
$missingStage = null;
// Upsert all stages for the team.
foreach ($sfStages as $sfStage) {
$selectable = true;
if (array_key_exists('IsActive', $sfStage)) {
$selectable = $sfStage['IsActive'];
}
$this->restoreAnyTrashedEntity($this->config->stages(), $sfStage['Id']);
$stage = $this->config->stages()->updateOrCreate([
'crm_provider_id' => $sfStage['Id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($sfStage['ApiName'], 0, 50),
'label' => mb_strimwidth($sfStage['MasterLabel'], 0, 191),
'type' => $type,
'sequence' => $sfStage['SortOrder'] ?? 0,
'is_selectable' => $selectable,
'probability' => $sfStage['DefaultProbability'] ?? null,
]);
if ($missingStageName && $missingStageName === $sfStage['ApiName']) {
$missingStage = $stage;
}
}
if ($missingStageName && $missingStage === null) {
// If they requested a stage that still doesn't exist, it must be inactive so lazy create it.
$missingStage = $this->config->stages()->create([
'crm_provider_id' => Uuid::uuid4(),
'team_id' => $this->team->id,
'name' => mb_strimwidth($missingStageName, 0, 50),
'label' => mb_strimwidth($missingStageName, 0, 191),
'type' => $type,
'sequence' => 0,
'is_selectable' => 0,
]);
}
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int
{
$syncCount = 0;
$fields = $this->getAllFieldsAsArray('lead');
if (\in_array('Id', $fields, true) === false) {
return $syncCount;
}
$query = '
SELECT ' . rtrim(implode(',', $fields), ',') . '
FROM Lead
WHERE LastModifiedDate > :since
ORDER BY LastModifiedDate ASC';
try {
$sfLeads = $this->queryHandler->query($query, [
'since' => $since->format('Y-m-d\TH:i:s\Z'),
]);
foreach ($sfLeads as $sfLead) {
// Only sync if previously imported.
if ($this->hasLead($sfLead['Id'])) {
$this->importLead($sfLead);
$syncCount++;
}
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
$this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::LEAD);
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncLead(string $crmId): ?Lead
{
$fields = $this->getAllFieldsAsArray('lead');
$sfLead = $this->getRecord('Lead', $crmId, $fields);
return $this->importLead($sfLead);
}
private function importLead($crmData): ?Lead
{
/** @var ?Stage $stage */
$stage = null;
if (isset($crmData['Status'])) {
// Get the current stage.
$stage = $this->config
->stages()
->where('name', $crmData['Status'])
->where('type', Stage::TYPE_LEAD)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_LEAD], $crmData['Status']);
}
}
// If we have no way of importing this, just return null :(
if ($stage === null) {
return null;
}
$countryCode = $crmData['CountryCode'] ?? null;
// Salesforce allows custom "countries" to be created. Disregard these.
if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {
$countryCode = null;
}
// If we have no country code, try to parse it from the country name.
if ($countryCode === null && empty($crmData['Country']) !== false) {
$countryCode = $this->convertCountryNameToCode($crmData['Country']);
}
// Trim to our width and attempt to parse it.
$number = mb_strimwidth($crmData['Phone'] ?? '', 0, 25);
$parsedNumber = parsePhoneNumber($countryCode, $number);
$mobilePhone = null;
if (empty($crmData['MobilePhone']) === false) {
// Trim to our width and attempt to parse it.
$number = mb_strimwidth($crmData['MobilePhone'], 0, 25);
$mobilePhone = phone_e164($countryCode, $number);
}
$convertedDate = null;
$convertedAccount = null;
$convertedOpportunity = null;
$convertedContact = null;
if ($crmData['IsConverted'] == 'true') {
$convertedDate = $crmData['ConvertedDate'];
if (empty($crmData['ConvertedAccountId']) === false) {
$convertedAccount = $this->config
->accounts()
->where('crm_provider_id', $crmData['ConvertedAccountId'])
->first();
if ($convertedAccount === null) {
try {
$convertedAccount = $this->syncAccount($crmData['ConvertedAccountId']);
} catch (HttpNotFoundException $exception) {
// Probably the user has no permissions to access the converted data.
}
}
}
if (empty($crmData['ConvertedOpportunityId']) === false) {
$convertedOpportunity = $this->config
->opportunities()
->where('crm_provider_id', $crmData['ConvertedOpportunityId'])
->first();
if ($convertedOpportunity === null) {
try {
$convertedOpportunity = $this->syncOpportunity($crmData['ConvertedOpportunityId']);
} catch (HttpNotFoundException $exception) {
// Probably the user has no permissions to access the converted data.
}
}
}
if (empty($crmData['ConvertedContactId']) === false) {
$convertedContact = $this->team
->crm
->contacts()
->where('crm_provider_id', $crmData['ConvertedContactId'])
->first();
if ($convertedContact === null) {
try {
$convertedContact = $this->syncContact($crmData['ConvertedContactId']);
} catch (HttpNotFoundException $exception) {
// Probably the user has no permissions to access the converted data.
}
}
}
}
if (empty($crmData['Company'])) {
$company = 'Unknown';
} else {
$company = mb_strimwidth($crmData['Company'], 0, 191);
}
$domain = null;
if (empty($crmData['Website']) === false) {
$domain = mb_strimwidth($crmData['Website'], 0, 191);
$domain = StringUtil::resolveDomain($domain);
}
$createdDate = null;
if (empty($crmData['CreatedDate']) === false) {
$createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');
}
$profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);
$data = [
'team_id' => $this->team->id,
'user_id' => $profile?->user_id,
'owner_id' => $crmData['OwnerId'] ?? '',
'company' => $company,
'domain' => $domain,
'name' => $crmData['Name'] ? mb_strimwidth($crmData['Name'], 0, 191) : '',
'title' => $crmData['Title'] ? mb_strimwidth($crmData['Title'], 0, 128) : null,
'email' => $crmData['Email'] ? mb_strimwidth($crmData['Email'], 0, 80) : null,
'phone' => $parsedNumber['phone'],
'ext' => $parsedNumber['ext'] ?? null,
'mobile_phone' => $mobilePhone,
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: $crmData['Id'],
modelType: Lead::class,
fileName: $crmData['Id'],
avatarText: $crmData['Name']
),
'stage_id' => $stage->id,
'record_type_id' => null,
'converted_at' => $convertedDate,
'converted_account_id' => $convertedAccount->id ?? null,
'converted_opportunity_id' => $convertedOpportunity->id ?? null,
'converted_contact_id' => $convertedContact->id ?? null,
'country_code' => $countryCode,
'remotely_created_at' => $createdDate,
];
$this->restoreAnyTrashedEntity($this->config->leads(), $crmData['Id']);
/** @var Lead $lead */
$lead = $this->config->leads()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);
if ($lead->wasChanged('converted_at') && $lead->getConvertedAt() !== null) {
$this->eventDispatcher->dispatch(new LeadConverted($lead));
}
$this->handleObjectDeletion($lead, $crmData);
return $lead;
}
/**
* @inheritdoc
*/
public function syncAccounts(Carbon $since, ?Carbon $to = null): int
{
$syncCount = 0;
$fields = $this->getAllFieldsAsArray('account');
if (\in_array('Id', $fields, true) === false) {
return $syncCount;
}
$query = '
SELECT ' . rtrim(implode(',', $fields), ',') . '
FROM Account
WHERE LastModifiedDate > :since
ORDER BY LastModifiedDate ASC';
try {
$sfAccounts = $this->queryHandler->query($query, [
'since' => $since->format('Y-m-d\TH:i:s\Z'),
]);
foreach ($sfAccounts as $sfAccount) {
// Only sync if previously imported.
if ($this->hasAccount($sfAccount['Id'])) {
$this->importAccount($sfAccount);
$syncCount++;
}
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
$this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::ACCOUNT);
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncAccount(string $crmId): ?Account
{
$fields = $this->getAllFieldsAsArray('account');
if (! in_array('Id', $fields, true)) {
$this->logger->info('[Salesforce] Sync account cancelled. Fields are not available.', [
'crmId' => $crmId,
'userId' => $this->profile->getUserId(),
]);
return null;
}
$sfAccount = $this->getRecord('Account', $crmId, $fields);
return $this->importAccount($sfAccount);
}
private function importAccount($crmData): Account
{
$countryCode = $crmData['BillingCountryCode'] ?? $crmData['ShippingCountryCode'] ?? null;
// Salesforce allows custom "countries" to be created. Disregard these.
if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {
$countryCode = null;
}
// If we have no country code, try to parse it from the country names.
if ($countryCode === null && empty($crmData['BillingCountry']) === false) {
$countryCode = $this->convertCountryNameToCode($crmData['BillingCountry']);
}
if ($countryCode === null && empty($crmData['ShippingCountry']) === false) {
$countryCode = $this->convertCountryNameToCode($crmData['ShippingCountry']);
}
if (empty($crmData['Phone']) === false) {
// Trim to our width and attempt to parse it.
$number = mb_strimwidth($crmData['Phone'], 0, 25);
$parsedNumber = parsePhoneNumber($countryCode, $number);
} else {
$parsedNumber = [];
}
$industry = null;
if (empty($crmData['Industry']) === false) {
$industry = mb_strimwidth($crmData['Industry'], 0, 40);
}
$domain = null;
if (empty($crmData['Website']) === false) {
$domain = mb_strimwidth($crmData['Website'], 0, 191);
$domain = StringUtil::resolveDomain($domain);
}
$createdDate = null;
if (empty($crmData['CreatedDate']) === false) {
$createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');
}
$profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);
$data = [
'team_id' => $this->team->id,
'user_id' => $profile?->user_id,
'owner_id' => $crmData['OwnerId'],
'name' => mb_strimwidth($crmData['Name'], 0, 191),
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: $crmData['Id'],
modelType: Account::class,
fileName: $crmData['Id'],
avatarText: $crmData['Name']
),
'industry' => $industry,
'domain' => $domain,
'phone' => $parsedNumber['phone'] ?? null,
'ext' => $parsedNumber['ext'] ?? null,
'country_code' => $countryCode,
'remotely_created_at' => $createdDate,
];
$this->restoreAnyTrashedEntity($this->config->accounts(), $crmData['Id']);
/** @var Account $account */
$account = $this->config->accounts()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);
$this->handleObjectDeletion($account, $crmData);
return $account;
}
/**
* @inheritdoc
*/
public function syncOpportunities(array $parameters, ?string $strategy = null): int
{
$strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);
$syncCount = 0;
$logParams = $parameters;
$parameters['profile'] = $this->profile;
$logParams['user'] = $this->profile->getUserId();
if (count($strategies) > 1) {
$this->logger->warning('[' . $this->getDisplayName() . '] Multiple sync strategies used', [
'teamId' => $this->team->getUuid(),
'params' => $logParams,
'strategies_count' => count($strategies),
]);
}
foreach ($strategies as $syncStrategy) {
$name = $syncStrategy->getStrategyName();
try {
$sfOpportunities = $syncStrategy->fetchOpportunities($parameters);
$totalRecords = $sfOpportunities->count();
foreach ($sfOpportunities as $sfOpportunity) {
$this->importOpportunity($sfOpportunity);
$syncCount++;
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
$this->logger->warning('[' . $this->getDisplayName() . '] No opportunities found', [
'teamId' => $this->team->getUuid(),
'name' => $name,
'params' => $logParams,
'reason' => $noResultsException->getMessage(),
]);
} catch (CrmException $crmException) {
// Nothing to sync.
$this->logger->warning('[' . $this->getDisplayName() . '] Opportunity sync failed', [
'teamId' => $this->team->getUuid(),
'name' => $name,
'params' => $logParams,
'reason' => $crmException->getMessage(),
]);
}
}
$this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::OPPORTUNITY, ['params' => $logParams]);
// debug to see how if count of opportunities reaches 1000
if ($syncCount >= 1000) {
$this->logger->info(
'[' . $this->getDisplayName() . '] Sync Opportunities - count warning',
[
'team_id' => $this->team->getId(),
'params' => $logParams,
'count' => $syncCount,
'strategies_count' => count($strategies),
'total_records' => $totalRecords ?? null,
]
);
}
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncOpportunity(string $crmId): ?Opportunity
{
$strategy = $this->opportunitySyncStrategyResolver->resolve(
$this->config,
OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY
);
$parameters = [
'profile' => $this->profile,
'crm_id' => $crmId,
];
try {
$sfOpportunity = $strategy->fetchOpportunities($parameters);
} catch (HttpNotFoundException $e) {
$this->logger->info('[' . $this->getDisplayName() . '] Opportunity not found', [
'teamId' => $this->team->id_string,
'crmId' => $crmId,
]);
return null;
} catch (CrmException $crmException) {
$this->logger->info('[' . $this->getDisplayName() . '] Opportunity sync failed', [
'teamId' => $this->team->id_string,
'crmId' => $crmId,
'exception' => $crmException->getMessage(),
]);
return null;
}
if ($sfOpportunity instanceof ArrayIterator) {
return $this->importOpportunity($sfOpportunity->getItems());
}
return $this->importOpportunity($sfOpportunity);
}
/**
* @throws HttpNotFoundException
*/
private function importOpportunity($crmData): ?Opportunity
{
$profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);
$account = null;
if (empty($crmData['AccountId']) === false) {
/** @var ?Account $account */
$account = $this->config->accounts()
->where('crm_provider_id', (string) $crmData['AccountId'])
->first();
if ($account === null) {
$account = $this->syncAccount($crmData['AccountId']);
}
}
$userId = $profile?->getUserId() ?? $account?->getUserId();
if ($userId...
|
70639
|
NULL
|
NULL
|
NULL
|
|
70639
|
2537
|
14
|
2026-05-22T10:26:36.374829+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779445596374_m2.jpg...
|
PhpStorm
|
Commit Changes
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Diff
Changelist:
Changelist:
Changes
Show Diff
Rol Diff
Changelist:
Changelist:
Changes
Show Diff
Rollback...
Refresh
Group By
Expand All
Collapse All
4 files, folder partially checked
app 2 files, folder partially checked...
|
[{"role":"AXStaticText","text& [{"role":"AXStaticText","text":"Diff","depth":1,"bounds":{"left":0.25531915,"top":0.39185953,"width":0.013962766,"height":0.013567438},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Changelist:","depth":1,"bounds":{"left":0.38231382,"top":0.15802075,"width":0.022938829,"height":0.027134877},"on_screen":true,"role_description":"text"},{"role":"AXPopUpButton","text":"Changelist:","depth":1,"bounds":{"left":0.40658244,"top":0.15802075,"width":0.042220745,"height":0.027134877},"on_screen":true,"role_description":"pop up button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Changes","depth":6,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Show Diff","depth":2,"bounds":{"left":0.25664893,"top":0.16201118,"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":"Rollback...","depth":2,"bounds":{"left":0.26529256,"top":0.16201118,"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":"Refresh","depth":2,"bounds":{"left":0.27393618,"top":0.16201118,"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":"Group By","depth":2,"bounds":{"left":0.28490692,"top":0.16201118,"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":"Expand All","depth":2,"bounds":{"left":0.3617021,"top":0.16201118,"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":"Collapse All","depth":2,"bounds":{"left":0.37034574,"top":0.16201118,"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":"AXStaticText","text":"4 files, folder partially checked","depth":4,"bounds":{"left":0.26462767,"top":0.13328013,"width":0.030585106,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"app 2 files, folder partially checked","depth":5,"bounds":{"left":0.27094415,"top":0.15083799,"width":0.037898935,"height":0.017557861},"on_screen":true,"role_description":"text"}]...
|
1045003121487834323
|
6036341212998085711
|
click
|
accessibility
|
NULL
|
Diff
Changelist:
Changelist:
Changes
Show Diff
Rol Diff
Changelist:
Changelist:
Changes
Show Diff
Rollback...
Refresh
Group By
Expand All
Collapse All
4 files, folder partially checked
app 2 files, folder partially checked...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
70638
|
2537
|
13
|
2026-05-22T10:26:35.257045+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779445595257_m2.jpg...
|
PhpStorm
|
Commit Changes
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Diff
Changelist:
Changelist:
Changes
Show Diff
Rol Diff
Changelist:
Changelist:
Changes
Show Diff
Rollback...
Refresh
Group By
Expand All
Collapse All
4 files, folder partially checked
app 2 files, folder partially checked
Console/Commands 1 file, folder not checked
Services/Crm/Salesforce 1 file, folder checked
Service.php, class checked
config 1 file, folder not checked
.env.local not checked
app 2 files, folder partially checked
Console/Commands 1 file, folder not checked
Services/Crm/Salesforce 1 file, folder checked
Service.php, class checked
Console/Commands 1 file, folder not checked
Services/Crm/Salesforce 1 file, folder checked
Service.php, class checked
Service.php, class checked
config 1 file, folder not checked
.env.local not checked
Unversioned Files 9 files not checked
1 modified
Sync Playbook categories and crm field values
Commit Message
Commit Message History
Git
Author:
Author:
Amend commit
Sign-off commit
Create extra commit with file movements
Commit Checks...
|
[{"role":"AXStaticText","text& [{"role":"AXStaticText","text":"Diff","depth":1,"bounds":{"left":0.25531915,"top":0.39185953,"width":0.013962766,"height":0.013567438},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Changelist:","depth":1,"bounds":{"left":0.38231382,"top":0.15802075,"width":0.022938829,"height":0.027134877},"on_screen":true,"role_description":"text"},{"role":"AXPopUpButton","text":"Changelist:","depth":1,"bounds":{"left":0.40658244,"top":0.15802075,"width":0.042220745,"height":0.027134877},"on_screen":true,"role_description":"pop up button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Changes","depth":6,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Show Diff","depth":2,"bounds":{"left":0.25664893,"top":0.16201118,"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":"Rollback...","depth":2,"bounds":{"left":0.26529256,"top":0.16201118,"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":"Refresh","depth":2,"bounds":{"left":0.27393618,"top":0.16201118,"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":"Group By","depth":2,"bounds":{"left":0.28490692,"top":0.16201118,"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":"Expand All","depth":2,"bounds":{"left":0.3617021,"top":0.16201118,"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":"Collapse All","depth":2,"bounds":{"left":0.37034574,"top":0.16201118,"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":"AXStaticText","text":"4 files, folder partially checked","depth":4,"bounds":{"left":0.26462767,"top":0.13328013,"width":0.030585106,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"app 2 files, folder partially checked","depth":5,"bounds":{"left":0.27094415,"top":0.15083799,"width":0.037898935,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Console/Commands 1 file, folder not checked","depth":6,"bounds":{"left":0.27726063,"top":0.16839585,"width":0.06881649,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Services/Crm/Salesforce 1 file, folder checked","depth":6,"bounds":{"left":0.27726063,"top":0.1859537,"width":0.078125,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Service.php, class checked","depth":7,"bounds":{"left":0.2835771,"top":0.20351157,"width":0.040226065,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"config 1 file, folder not checked","depth":5,"bounds":{"left":0.27094415,"top":0.22106944,"width":0.040226065,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":".env.local not checked","depth":5,"bounds":{"left":0.27094415,"top":0.2386273,"width":0.03557181,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"app 2 files, folder partially checked","depth":4,"bounds":{"left":0.27094415,"top":0.15083799,"width":0.037898935,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Console/Commands 1 file, folder not checked","depth":5,"bounds":{"left":0.27726063,"top":0.16839585,"width":0.06881649,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Services/Crm/Salesforce 1 file, folder checked","depth":5,"bounds":{"left":0.27726063,"top":0.1859537,"width":0.078125,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Service.php, class checked","depth":6,"bounds":{"left":0.2835771,"top":0.20351157,"width":0.040226065,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Console/Commands 1 file, folder not checked","depth":4,"bounds":{"left":0.27726063,"top":0.16839585,"width":0.06881649,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Services/Crm/Salesforce 1 file, folder checked","depth":4,"bounds":{"left":0.27726063,"top":0.1859537,"width":0.078125,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Service.php, class checked","depth":5,"bounds":{"left":0.2835771,"top":0.20351157,"width":0.040226065,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Service.php, class checked","depth":4,"bounds":{"left":0.2835771,"top":0.20351157,"width":0.040226065,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"config 1 file, folder not checked","depth":4,"bounds":{"left":0.27094415,"top":0.22106944,"width":0.040226065,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":".env.local not checked","depth":4,"bounds":{"left":0.27094415,"top":0.2386273,"width":0.03557181,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Unversioned Files 9 files not checked","depth":4,"bounds":{"left":0.26462767,"top":0.25618514,"width":0.060837764,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1 modified","depth":1,"bounds":{"left":0.25531915,"top":0.254589,"width":0.1931516,"height":0.016759777},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"Sync Playbook categories and crm field values","depth":2,"bounds":{"left":0.2556516,"top":0.3008779,"width":0.19281915,"height":0.087789305},"on_screen":true,"value":"Sync Playbook categories and crm field values","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Commit Message","depth":1,"bounds":{"left":0.25531915,"top":0.28252193,"width":0.03557181,"height":0.013567438},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Commit Message History","depth":2,"bounds":{"left":0.4401596,"top":0.27853152,"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":"AXStaticText","text":"Git","depth":2,"bounds":{"left":0.45212767,"top":0.16280925,"width":0.0056515955,"height":0.013567438},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Author:","depth":2,"bounds":{"left":0.46010637,"top":0.18834797,"width":0.014960106,"height":0.013567438},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"Author:","depth":3,"bounds":{"left":0.4787234,"top":0.18834797,"width":0.09840426,"height":0.013567438},"on_screen":true,"role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Amend commit","depth":2,"bounds":{"left":0.4587766,"top":0.21069433,"width":0.039893616,"height":0.019952115},"on_screen":true,"help_text":"Merge this commit with the previous one","role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Sign-off commit","depth":2,"bounds":{"left":0.4587766,"top":0.23383878,"width":0.042220745,"height":0.019952115},"on_screen":true,"help_text":"<html>Adds the following line at the end of the commit message:<br/>Signed-off by: Lukas Kovalik <kovaliklukas@gmail.com></html>","role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Create extra commit with file movements","depth":2,"on_screen":false,"role_description":"checkbox","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Commit Checks","depth":2,"bounds":{"left":0.45212767,"top":0.27613726,"width":0.032247342,"height":0.013567438},"on_screen":true,"role_description":"text"}]...
|
-5551080977874489699
|
-2658143708460861620
|
click
|
accessibility
|
NULL
|
Diff
Changelist:
Changelist:
Changes
Show Diff
Rol Diff
Changelist:
Changelist:
Changes
Show Diff
Rollback...
Refresh
Group By
Expand All
Collapse All
4 files, folder partially checked
app 2 files, folder partially checked
Console/Commands 1 file, folder not checked
Services/Crm/Salesforce 1 file, folder checked
Service.php, class checked
config 1 file, folder not checked
.env.local not checked
app 2 files, folder partially checked
Console/Commands 1 file, folder not checked
Services/Crm/Salesforce 1 file, folder checked
Service.php, class checked
Console/Commands 1 file, folder not checked
Services/Crm/Salesforce 1 file, folder checked
Service.php, class checked
Service.php, class checked
config 1 file, folder not checked
.env.local not checked
Unversioned Files 9 files not checked
1 modified
Sync Playbook categories and crm field values
Commit Message
Commit Message History
Git
Author:
Author:
Amend commit
Sign-off commit
Create extra commit with file movements
Commit Checks...
|
70637
|
NULL
|
NULL
|
NULL
|
|
70640
|
2536
|
11
|
2026-05-22T10:26:35.153463+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779445595153_m1.jpg...
|
PhpStorm
|
Commit Changes
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Diff
Changelist:
Changelist:
Changes
Show Diff
Rol Diff
Changelist:
Changelist:
Changes
Show Diff
Rollback...
Refresh
Group By
Expand All
Collapse All...
|
[{"role":"AXStaticText","text& [{"role":"AXStaticText","text":"Diff","depth":1,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Changelist:","depth":1,"on_screen":true,"role_description":"text"},{"role":"AXPopUpButton","text":"Changelist:","depth":1,"on_screen":true,"role_description":"pop up button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Changes","depth":6,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Show Diff","depth":2,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Rollback...","depth":2,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Refresh","depth":2,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Group By","depth":2,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand All","depth":2,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse All","depth":2,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
-6625429526804351588
|
-4501932382348243689
|
click
|
hybrid
|
NULL
|
Diff
Changelist:
Changelist:
Changes
Show Diff
Rol Diff
Changelist:
Changelist:
Changes
Show Diff
Rollback...
Refresh
Group By
Expand All
Collapse All
SlackFile EditViewGoHistoryWindowHelpAPP (DOCKERDEV (-zsh)О 882APP (-zsh)tests/Unit/Services/Mail/0ffice/EmailApiClientTest.phptests/Unit/Services/Mail/TextRelayServiceTest.phptests/Unit/Services/RecallAI/RecallAIServiceTest.php79 files changed, 4254 insertions(+), 2020 deletions(-)create mode 100644•github/claude-reviewer/no-ticket-warning.txtcreate mode.github/claude-reviewer/scripts/fetch-jira-context.mjsdelete mode100644 app/Component/ES/Processor/BatchStatusInterface.phpcreate mode100644 app/Component/ES/Processor/RedisEntityExtractor.phpdelete mode100644 app/Component/ES/Processor/Traits/WorkerIdTrait.php100644 app/Component/ES/QueuePriorityEnum.phpcreate mode100644 front-end/src/components/DealInsights/Modals/__tests__/AskAnythingModacreatemode 100644 front-end/src/components/shared/AskAnything/__tests__/__snapshots__/Asdeletemode100644 front-end/src/components/shared/AskAnything/__tests__/__snapshots_/Ascreatemode100644 tests/Unit/Component/ES/Processor/RedisEntityExtractorTest.phpdelete mode100644 tests/Unit/Component/ES/Processor/Traits/WorkerIdTraitTest.phpcreate mode100644 tests/Unit/Component/ES/QueuePriorityEnumTest.phpcreate mode100644 tests/Unit/Notifications/Activities/SmsReceivedTest.phpcreate mode100644 tests/Unit/Services/Mail/TextRelayServiceTest.phplukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ co -b JY-20963-fix-imporSwitched to a new branch 'JY-20963-fix-import-on-deleted-entity'lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/Jiminny/app (JY-20963-fix-import-on-deleted-ent|docker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix--config=.php-cs-fixer.dist.phWhat's next:Try Docker Debug forseamless, persistent debugging tools in any container or image →Learn moreat [URL_WITH_CREDENTIALS] ~/jiminny/app (JY-20963-fix-import-on-deleted-ent)docker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.phPHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminski15 umconafi amp cont-fbteo-di5t. phPHP runtime: 8.3.30Running analysis on 7 cores with 10 files per process.Parallel runner is an experimental feature and may be unstable, use it at your own risk.Loaded config default from".php-cs-fixer.dist.php".5691/5691 [8100%Fixed 0 of 5691 files in 96.077 seconds, 67.00 MB memory usedWhat's next:Try Docker Debug for seamless, persistent debugging tools in anycontainer or imageLearn moreat [URL_WITH_CREDENTIALS] review requested by @VasilVasilev#12120 Jy 20842 add deleteevents for es by @Vasil Vasilev9 commits • 14 files changedJIRA: JY-20842Changes:• Introduce events and eventlisteners that allow sending"document deleted" events for ESconsumption - both Hard and Softdeleted documents will beremoved.• Loa...Show morejiminny/appAdded by Toast for GitHubMessage Toast+Aa..•...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
70637
|
2537
|
12
|
2026-05-22T10:26:33.406603+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779445593406_m2.jpg...
|
PhpStorm
|
Commit Changes
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Diff
Changelist:
Changelist:
Changes
Show Diff
Rol Diff
Changelist:
Changelist:
Changes
Show Diff
Rollback...
Refresh
Group By
Expand All
Collapse All
4 files, folder partially checked
app 2 files, folder partially checked
Console/Commands 1 file, folder not checked
Services/Crm/Salesforce 1 file, folder checked
Service.php, class checked
config 1 file, folder not checked
.env.local not checked
app 2 files, folder partially checked
Console/Commands 1 file, folder not checked
Services/Crm/Salesforce 1 file, folder checked
Service.php, class checked
Console/Commands 1 file, folder not checked
Services/Crm/Salesforce 1 file, folder checked
Service.php, class checked
Service.php, class checked
config 1 file, folder not checked
.env.local not checked
Unversioned Files 9 files not checked
1 modified
Sync Playbook categories and crm field values
Commit Message
Commit Message History
Git
Author:
Author:
Amend commit
Sign-off commit
Create extra commit with file movements
Commit Checks
Update copyright
Reformat code
Rearrange code
Optimize imports
Cleanup
Choose profile
Check malicious dependencies
Run external formatter
Configure
Perform SonarQube for IDE analysis
Find unused view files
Run Git hooks
Advanced Commit Checks
Analyze code
Choose profile
Check TODO
Configure
After Commit
Upload files to:
Upload files to:
<None>...
|
[{"role":"AXStaticText","text& [{"role":"AXStaticText","text":"Diff","depth":1,"bounds":{"left":0.25531915,"top":0.39185953,"width":0.013962766,"height":0.013567438},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Changelist:","depth":1,"bounds":{"left":0.38231382,"top":0.15802075,"width":0.022938829,"height":0.027134877},"on_screen":true,"role_description":"text"},{"role":"AXPopUpButton","text":"Changelist:","depth":1,"bounds":{"left":0.40658244,"top":0.15802075,"width":0.042220745,"height":0.027134877},"on_screen":true,"role_description":"pop up button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Changes","depth":6,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Show Diff","depth":2,"bounds":{"left":0.25664893,"top":0.16201118,"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":"Rollback...","depth":2,"bounds":{"left":0.26529256,"top":0.16201118,"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":"Refresh","depth":2,"bounds":{"left":0.27393618,"top":0.16201118,"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":"Group By","depth":2,"bounds":{"left":0.28490692,"top":0.16201118,"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":"Expand All","depth":2,"bounds":{"left":0.3617021,"top":0.16201118,"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":"Collapse All","depth":2,"bounds":{"left":0.37034574,"top":0.16201118,"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":"AXStaticText","text":"4 files, folder partially checked","depth":4,"bounds":{"left":0.26462767,"top":0.13328013,"width":0.030585106,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"app 2 files, folder partially checked","depth":5,"bounds":{"left":0.27094415,"top":0.15083799,"width":0.037898935,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Console/Commands 1 file, folder not checked","depth":6,"bounds":{"left":0.27726063,"top":0.16839585,"width":0.06881649,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Services/Crm/Salesforce 1 file, folder checked","depth":6,"bounds":{"left":0.27726063,"top":0.1859537,"width":0.078125,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Service.php, class checked","depth":7,"bounds":{"left":0.2835771,"top":0.20351157,"width":0.040226065,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"config 1 file, folder not checked","depth":5,"bounds":{"left":0.27094415,"top":0.22106944,"width":0.040226065,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":".env.local not checked","depth":5,"bounds":{"left":0.27094415,"top":0.2386273,"width":0.03557181,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"app 2 files, folder partially checked","depth":4,"bounds":{"left":0.27094415,"top":0.15083799,"width":0.037898935,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Console/Commands 1 file, folder not checked","depth":5,"bounds":{"left":0.27726063,"top":0.16839585,"width":0.06881649,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Services/Crm/Salesforce 1 file, folder checked","depth":5,"bounds":{"left":0.27726063,"top":0.1859537,"width":0.078125,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Service.php, class checked","depth":6,"bounds":{"left":0.2835771,"top":0.20351157,"width":0.040226065,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Console/Commands 1 file, folder not checked","depth":4,"bounds":{"left":0.27726063,"top":0.16839585,"width":0.06881649,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Services/Crm/Salesforce 1 file, folder checked","depth":4,"bounds":{"left":0.27726063,"top":0.1859537,"width":0.078125,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Service.php, class checked","depth":5,"bounds":{"left":0.2835771,"top":0.20351157,"width":0.040226065,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Service.php, class checked","depth":4,"bounds":{"left":0.2835771,"top":0.20351157,"width":0.040226065,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"config 1 file, folder not checked","depth":4,"bounds":{"left":0.27094415,"top":0.22106944,"width":0.040226065,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":".env.local not checked","depth":4,"bounds":{"left":0.27094415,"top":0.2386273,"width":0.03557181,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Unversioned Files 9 files not checked","depth":4,"bounds":{"left":0.26462767,"top":0.25618514,"width":0.060837764,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1 modified","depth":1,"bounds":{"left":0.25531915,"top":0.254589,"width":0.1931516,"height":0.016759777},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"Sync Playbook categories and crm field values","depth":2,"bounds":{"left":0.2556516,"top":0.3008779,"width":0.19281915,"height":0.087789305},"on_screen":true,"value":"Sync Playbook categories and crm field values","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Commit Message","depth":1,"bounds":{"left":0.25531915,"top":0.28252193,"width":0.03557181,"height":0.013567438},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Commit Message History","depth":2,"bounds":{"left":0.4401596,"top":0.27853152,"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":"AXStaticText","text":"Git","depth":2,"bounds":{"left":0.45212767,"top":0.16280925,"width":0.0056515955,"height":0.013567438},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Author:","depth":2,"bounds":{"left":0.46010637,"top":0.18834797,"width":0.014960106,"height":0.013567438},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"Author:","depth":3,"bounds":{"left":0.4787234,"top":0.18834797,"width":0.09840426,"height":0.013567438},"on_screen":true,"role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Amend commit","depth":2,"bounds":{"left":0.4587766,"top":0.21069433,"width":0.039893616,"height":0.019952115},"on_screen":true,"help_text":"Merge this commit with the previous one","role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Sign-off commit","depth":2,"bounds":{"left":0.4587766,"top":0.23383878,"width":0.042220745,"height":0.019952115},"on_screen":true,"help_text":"<html>Adds the following line at the end of the commit message:<br/>Signed-off by: Lukas Kovalik <kovaliklukas@gmail.com></html>","role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Create extra commit with file movements","depth":2,"on_screen":false,"role_description":"checkbox","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Commit Checks","depth":2,"bounds":{"left":0.45212767,"top":0.27613726,"width":0.032247342,"height":0.013567438},"on_screen":true,"role_description":"text"},{"role":"AXCheckBox","text":"Update copyright","depth":2,"bounds":{"left":0.4587766,"top":0.29608938,"width":0.04488032,"height":0.019952115},"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Reformat code","depth":2,"bounds":{"left":0.4587766,"top":0.31923383,"width":0.03956117,"height":0.019952115},"on_screen":true,"help_text":"","role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Rearrange code","depth":2,"bounds":{"left":0.4587766,"top":0.3423783,"width":0.041888297,"height":0.019952115},"on_screen":true,"help_text":"","role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Optimize imports","depth":2,"bounds":{"left":0.4587766,"top":0.36552274,"width":0.044215426,"height":0.019952115},"on_screen":true,"help_text":"","role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Cleanup","depth":2,"bounds":{"left":0.4587766,"top":0.3886672,"width":0.026263298,"height":0.019952115},"on_screen":true,"help_text":"","role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"Choose profile","depth":2,"bounds":{"left":0.48902926,"top":0.39185953,"width":0.029920213,"height":0.013567438},"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Check malicious dependencies","depth":2,"bounds":{"left":0.4587766,"top":0.41181165,"width":0.07247341,"height":0.019952115},"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Run external formatter","depth":2,"bounds":{"left":0.4587766,"top":0.4349561,"width":0.05518617,"height":0.019952115},"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"Configure","depth":2,"bounds":{"left":0.51795214,"top":0.43814844,"width":0.019946808,"height":0.013567438},"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Perform SonarQube for IDE analysis","depth":2,"bounds":{"left":0.4587766,"top":0.45810056,"width":0.1200133,"height":0.019952115},"on_screen":true,"help_text":"","role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Find unused view files","depth":2,"bounds":{"left":0.4587766,"top":0.481245,"width":0.054521278,"height":0.019952115},"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Run Git hooks","depth":2,"on_screen":false,"help_text":"If unchecked, Git hooks will be skipped with the '--no-verify' option for the upcoming commit","role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Advanced Commit Checks","depth":2,"bounds":{"left":0.45212767,"top":0.5235435,"width":0.05418883,"height":0.013567438},"on_screen":true,"role_description":"text"},{"role":"AXCheckBox","text":"Analyze code","depth":2,"bounds":{"left":0.4587766,"top":0.5434956,"width":0.036901597,"height":0.019952115},"on_screen":true,"help_text":"","role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"Choose profile","depth":2,"bounds":{"left":0.49966756,"top":0.54668796,"width":0.029920213,"height":0.013567438},"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Check TODO","depth":2,"bounds":{"left":0.4587766,"top":0.5666401,"width":0.035904255,"height":0.019952115},"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"Configure","depth":2,"bounds":{"left":0.49867022,"top":0.5698324,"width":0.019946808,"height":0.013567438},"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"After Commit","depth":2,"bounds":{"left":0.45212767,"top":0.6089386,"width":0.027260639,"height":0.013567438},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Upload files to:","depth":2,"bounds":{"left":0.46010637,"top":0.6360734,"width":0.030585106,"height":0.013567438},"on_screen":true,"role_description":"text"},{"role":"AXPopUpButton","text":"Upload files to:","depth":2,"bounds":{"left":0.49168882,"top":0.62968874,"width":0.07679521,"height":0.027134877},"on_screen":true,"role_description":"pop up button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"<None>","depth":7,"on_screen":false,"role_description":"text"}]...
|
6212958302489721950
|
-2660105503492786360
|
visual_change
|
accessibility
|
NULL
|
Diff
Changelist:
Changelist:
Changes
Show Diff
Rol Diff
Changelist:
Changelist:
Changes
Show Diff
Rollback...
Refresh
Group By
Expand All
Collapse All
4 files, folder partially checked
app 2 files, folder partially checked
Console/Commands 1 file, folder not checked
Services/Crm/Salesforce 1 file, folder checked
Service.php, class checked
config 1 file, folder not checked
.env.local not checked
app 2 files, folder partially checked
Console/Commands 1 file, folder not checked
Services/Crm/Salesforce 1 file, folder checked
Service.php, class checked
Console/Commands 1 file, folder not checked
Services/Crm/Salesforce 1 file, folder checked
Service.php, class checked
Service.php, class checked
config 1 file, folder not checked
.env.local not checked
Unversioned Files 9 files not checked
1 modified
Sync Playbook categories and crm field values
Commit Message
Commit Message History
Git
Author:
Author:
Amend commit
Sign-off commit
Create extra commit with file movements
Commit Checks
Update copyright
Reformat code
Rearrange code
Optimize imports
Cleanup
Choose profile
Check malicious dependencies
Run external formatter
Configure
Perform SonarQube for IDE analysis
Find unused view files
Run Git hooks
Advanced Commit Checks
Analyze code
Choose profile
Check TODO
Configure
After Commit
Upload files to:
Upload files to:
<None>...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
70636
|
2537
|
11
|
2026-05-22T10:26:11.998026+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779445571998_m2.jpg...
|
PhpStorm
|
faVsco.js – ServiceTest.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20963-fix-import-on-de Project: faVsco.js, menu
JY-20963-fix-import-on-deleted-entity, menu
Start Listening for PHP Debug Connections
ServiceTest
Run 'ServiceTest'
Debug 'ServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Built-in Preview
Chrome
Firefox
Safari
Sync Changes
Hide This Notification
Code changed:
Hide
4
32
168
1
28
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Tests\Unit\Services\Crm\Salesforce;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Events\Dispatcher;
use Illuminate\Support\Facades\Cache;
use Jiminny\Component\Country\CountriesMap;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Enums\CrmObject;
use Jiminny\Exceptions\InvalidArgumentException;
use Jiminny\Exceptions\NoResultsException;
use Jiminny\Integrations\PlaybookResolver;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Playbook;
use Jiminny\Models\Team;
use Jiminny\Models\TeamSettings;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\ContactRoleRepository;
use Jiminny\Repositories\Crm\FieldDataRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\Salesforce\Client;
use Jiminny\Services\Crm\Salesforce\PayloadBuilder;
use Jiminny\Services\Crm\Salesforce\QueryBuilder;
use Jiminny\Services\Crm\Salesforce\QueryHandler;
use Jiminny\Services\Crm\Salesforce\QueryIterator;
use Jiminny\Services\Crm\Salesforce\QueryResults;
use Jiminny\Services\Crm\Salesforce\Service;
use PHPUnit\Framework\MockObject\MockObject;
use Tests\TestCase;
use Tests\Unit\Traits\TestPrivateMethod;
class ServiceTest extends TestCase
{
use TestPrivateMethod;
public function testFetchAndAssociateRelatedActivity(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$payloadBuilder->method('addCustomLogicFieldsPayload')
->willReturnCallback(function ($activity, $payload) {
return $payload;
});
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods(['fetchRelatedActivity', 'getPlaybook', 'getPlaybookCategory', 'updateRecord'])
->getMock();
$serviceMock->expects($this->once())
->method('fetchRelatedActivity')
->willReturn([
'Id' => 'testId',
'Type' => null,
'OwnerId' => 'testerUser',
'Description' => 'Test description',
]);
$user = $this->createMock(User::class);
$team = $this->createMock(Team::class);
$user->method('getAttribute')->with('team')->willReturn($team);
$playbook = $this->createMock(Playbook::class);
$playbook->method('getActivityField')->willReturn(null);
$playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_EVENT);
$serviceMock->expects($this->once())
->method('getPlaybook')
->with($user)
->willReturn($playbook);
$serviceMock->expects($this->never())
->method('getPlaybookCategory');
$serviceMock->expects($this->never())
->method('updateRecord');
$fieldDataRepository = $this->createMock(FieldDataRepository::class);
$fieldDataRepository->method('getActivityFieldData')->willReturn(collect([]));
app()->instance(FieldDataRepository::class, $fieldDataRepository);
$config = $this->createMock(Configuration::class);
$profilesRelation = $this->getMockBuilder(\Illuminate\Database\Eloquent\Relations\HasMany::class)
->disableOriginalConstructor()
->onlyMethods(['get'])
->addMethods(['where'])
->getMock();
$profilesRelation->method('where')->willReturnSelf();
$profilesRelation->method('get')->willReturn(collect([]));
$config->method('profiles')->willReturn($profilesRelation);
$serviceMock->config = $config;
$serviceMock->profile = null;
$actualStartTime = \Carbon\Carbon::now();
$activity = $this->getMockBuilder(Activity::class)
->disableOriginalConstructor()
->onlyMethods(['update', 'hasProspect'])
->getMock();
$activity->method('update')->willReturn(true);
$activity->method('hasProspect')->willReturn(true);
$activity->type = Activity::TYPE_CONFERENCE;
$activity->provider = Activity::PROVIDER_TWILIO;
$activity->lead_id = 1;
$activity->user_id = 0;
$activity->id_string = 'test-activity-id';
$activity->user = $user;
$activity->actual_start_time = $actualStartTime;
$activity->uuid = 'c53d8320-f556-4cee-a2f8-5f232f454ca4';
app()->bind(PlaybookResolver::class, function () use ($user) {
$playbook = $this->createMock(Playbook::class);
$playbookResolver = $this->createMock(PlaybookResolver::class);
$playbookResolver->expects($this->once())
->method('resolvePlaybookByUser')
->with($user)
->willReturn($playbook);
return $playbookResolver;
});
$data = $serviceMock->fetchAndAssociateRelatedActivity($activity);
$this->assertInstanceOf(Activity::class, $data);
$this->assertEquals(Activity::TYPE_CONFERENCE, $data->getType());
$this->assertEquals($actualStartTime->getTimestamp(), $data->getActualStartTime()->getTimestamp());
}
public function testFetchAndAssociateRelatedActivitySkipsForTaskBasedPlaybook(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods(['fetchRelatedActivity', 'getPlaybook'])
->getMock();
$user = $this->createMock(User::class);
$playbook = $this->createMock(Playbook::class);
$playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);
$playbook->method('getId')->willReturn(123);
$serviceMock->expects($this->once())
->method('getPlaybook')
->with($user)
->willReturn($playbook);
$serviceMock->expects($this->never())
->method('fetchRelatedActivity');
$activity = $this->getMockBuilder(Activity::class)
->disableOriginalConstructor()
->onlyMethods(['hasProspect', 'getUuid'])
->getMock();
$activity->method('hasProspect')->willReturn(true);
$activity->method('getUuid')->willReturn('c53d8320-f556-4cee-a2f8-5f232f454ca4');
$activity->type = Activity::TYPE_CONFERENCE;
$activity->actual_start_time = \Carbon\Carbon::now();
$activity->user = $user;
$result = $serviceMock->fetchAndAssociateRelatedActivity($activity);
$this->assertNull($result);
}
public function testFetchAndAssociateRelatedActivityReturnsNullForNonConference(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$serviceMock = new Service(
client: $client,
payloadBuilder: $payloadBuilder,
eventDispatcher: $eventDispatcher,
countriesMap: $countriesMap,
prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)
);
$activity = $this->getMockBuilder(Activity::class)
->disableOriginalConstructor()
->getMock();
$activity->type = Activity::TYPE_SOFTPHONE;
$result = $serviceMock->fetchAndAssociateRelatedActivity($activity);
$this->assertNull($result);
}
public function testFetchAndAssociateRelatedActivityReturnsNullWhenNoStartTime(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$serviceMock = new Service(
client: $client,
payloadBuilder: $payloadBuilder,
eventDispatcher: $eventDispatcher,
countriesMap: $countriesMap,
prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)
);
$activity = $this->getMockBuilder(Activity::class)
->disableOriginalConstructor()
->getMock();
$activity->type = Activity::TYPE_CONFERENCE;
$activity->actual_start_time = null;
$activity->scheduled_start_time = null;
$result = $serviceMock->fetchAndAssociateRelatedActivity($activity);
$this->assertNull($result);
}
public function testFetchAndAssociateRelatedActivityReturnsNullWhenNoProspect(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods(['getPlaybook'])
->getMock();
$serviceMock->expects($this->never())
->method('getPlaybook');
$activity = $this->getMockBuilder(Activity::class)
->disableOriginalConstructor()
->onlyMethods(['hasProspect', 'getUuid'])
->getMock();
$activity->method('hasProspect')->willReturn(false);
$activity->method('getUuid')->willReturn('c53d8320-f556-4cee-a2f8-5f232f454ca4');
$activity->type = Activity::TYPE_CONFERENCE;
$activity->actual_start_time = \Carbon\Carbon::now();
$result = $serviceMock->fetchAndAssociateRelatedActivity($activity);
$this->assertNull($result);
}
public function testMatchExactlyByEmail(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods([])
->getMock();
$profile = new Profile();
$profile->setAttribute('id', bin2hex(random_bytes(8)));
$serviceMock->profile = $profile;
$team = $this->createMock(Team::class);
$serviceMock->team = $team;
$data = $serviceMock->matchExactlyByEmail(bin2hex(random_bytes(8)) . '[EMAIL]');
$this->assertEquals(null, $data);
}
public function testMatchDomainFromEmail(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$queryIterator = $this->createMock(QueryIterator::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$config = $this->createMock(Configuration::class);
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('search')
->willReturn($queryIterator);
return $handler;
});
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods(['convertCrmData'])
->getMock();
$profile = new Profile();
$profile->account_fields = 'Field1, Field2, Field3';
$serviceMock->profile = $profile;
$serviceMock->expects($this->once())
->method('convertCrmData')
->willReturn(['test']);
$this->app->bind(QueryBuilder::class, function () {
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->expects($this->once())
->method('buildMatchByDomainQuery')
->with('[EMAIL]')
->willReturn('FIND {[EMAIL]} IN ALL FIELDS RETURNING Account(Id)');
return $queryBuilder;
});
$team = $this->createMock(Team::class);
$serviceMock->team = $team;
$serviceMock->config = $config;
$data = $serviceMock->matchByDomain('[EMAIL]');
$this->assertEquals(['test'], $data);
}
public function testBuildTaskSearchFields(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$service = new Service(
client: $client,
payloadBuilder: $payloadBuilder,
eventDispatcher: $eventDispatcher,
countriesMap: $countriesMap,
prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)
);
$fields = $service->buildTaskSearchFields();
$expectedFields = ['Id', 'WhoId', 'WhatId', 'AccountId'];
$this->assertEquals($expectedFields, $fields);
}
public function testMapCrmObjects(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$service = new Service(
client: $client,
payloadBuilder: $payloadBuilder,
eventDispatcher: $eventDispatcher,
countriesMap: $countriesMap,
prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class),
);
$sampleTask = [
'WhoId' => '003sampleWhoId',
'AccountId' => 'sampleAccountId',
'WhatId' => 'sampleWhatId',
];
$activityData = $service->mapCrmObjects($sampleTask);
$expectedActivityData = [
'contact' => '003sampleWhoId',
'account' => 'sampleAccountId',
'opportunity' => 'sampleWhatId',
];
$this->assertEquals($expectedActivityData, $activityData);
}
public function testGetInstalledAppVersion(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$queryIterator = $this->createMock(QueryIterator::class);
$queryIterator->expects($this->any())
->method('current')->willReturn([
'SubscriberPackageVersion' => [
'MajorVersion' => '1',
'MinorVersion' => '0',
'PatchVersion' => '1',
'BuildNumber' => '0',
],
]);
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('metadata')
->willReturn($queryIterator);
return $handler;
});
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods(array_diff(get_class_methods(Service::class), ['getInstalledAppVersion']))
->getMock();
$version = $serviceMock->getInstalledAppVersion();
$this->assertEquals('1010', $version);
}
public function testSyncProfiles(): void
{
$userToSearch = null;
$team = $this->createMock(Team::class);
$config = $this->createMockedConfiguration();
$config->expects($this->once())
->method('getId')
->willReturn(1);
$salesforceUser = [
'Email' => '[EMAIL]',
'UserPreferencesLightningExperiencePreferred' => true,
'CallCenterId' => '123',
'Id' => '456',
'ProfileId' => '789',
];
app()->bind(QueryBuilder::class, function () use ($userToSearch) {
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->expects($this->once())
->method('buildGetUsersQuery')
->with($userToSearch)
->willReturn('SELECT * FROM Users');
return $queryBuilder;
});
$queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('query')
->with('SELECT * FROM Users')
->willReturn($queryIterator);
return $handler;
});
$user = $this->createMock(User::class);
$user->expects($this->once())
->method('getId')
->willReturn(123);
$this->mockTeamRepository($team, $salesforceUser, $user);
$profileRepository = $this->createMock(ProfileRepository::class);
$profileRepository->expects($this->once())
->method('updateOrCreateProfile')
->with(
$user,
[
'crm_configuration_id' => 1,
'crm_provider_id' => '456',
],
[
'user_id' => 123,
'edition' => Profile::EDITION_LIGHTNING,
'has_external_cti' => true,
'crm_profile_id' => '789',
]
)
->willReturn(new Profile());
$this->app->instance(ProfileRepository::class, $profileRepository);
$serviceMock = $this->getServiceMock();
$serviceMock->team = $team;
$serviceMock->config = $config;
$result = $serviceMock->syncProfiles($userToSearch);
$this->assertNull($result);
}
public function testSyncProfilesEmailIsNull(): void
{
$userToSearch = $this->createMock(User::class);
$salesforceUser = [
'Email' => null,
];
app()->bind(QueryBuilder::class, function () {
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->expects($this->once())
->method('buildGetUsersQuery')
->with(null)
->willReturn('SELECT * FROM Users');
return $queryBuilder;
});
$queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->exactly(2))
->method('query')
->willReturn($queryIterator);
return $handler;
});
$team = $this->createMock(Team::class);
$user = $this->createMock(User::class);
$this->mockTeamRepository($team, $salesforceUser, $user, false);
$config = $this->createMock(Configuration::class);
$serviceMock = $this->getServiceMock();
$serviceMock->team = $team;
$serviceMock->config = $config;
$profile = $serviceMock->syncProfiles(null);
$this->assertNull($profile);
}
public function testSyncProfilesUserToSearchMatchesCurrentUser(): void
{
$userToSearch = $this->createMock(User::class);
$userToSearch->expects($this->once())
->method('getId')
->willReturn(123);
$team = $this->createMock(Team::class);
$config = $this->createMock(Configuration::class);
$config->expects($this->once())
->method('getId')
->willReturn(1);
$salesforceUser = [
'Email' => '[EMAIL]',
'UserPreferencesLightningExperiencePreferred' => true,
'CallCenterId' => '123',
'Id' => '456',
'ProfileId' => '789',
];
app()->bind(QueryBuilder::class, function () use ($userToSearch) {
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->expects($this->once())
->method('buildGetUsersQuery')
->with($userToSearch)
->willReturn('SELECT * FROM Users');
return $queryBuilder;
});
$queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('query')
->with('SELECT * FROM Users')
->willReturn($queryIterator);
return $handler;
});
$user = $this->createMock(User::class);
$user->expects($this->exactly(2))
->method('getId')
->willReturn(123);
$this->mockTeamRepository($team, $salesforceUser, $user);
$profileRepository = $this->createMock(ProfileRepository::class);
$profileRepository->expects($this->once())
->method('updateOrCreateProfile')
->with(
$user,
[
'crm_configuration_id' => 1,
'crm_provider_id' => '456',
],
[
'user_id' => 123,
'edition' => Profile::EDITION_LIGHTNING,
'has_external_cti' => true,
'crm_profile_id' => '789',
]
)
->willReturn(new Profile());
$this->app->instance(ProfileRepository::class, $profileRepository);
$serviceMock = $this->getServiceMock();
$serviceMock->team = $team;
$serviceMock->config = $config;
$profile = $serviceMock->syncProfiles($userToSearch);
$this->assertInstanceOf(Profile::class, $profile);
}
public function testSyncProfilesWithCustomValidation(): void
{
$userToSearch = null;
$team = $this->createMock(Team::class);
$config = $this->createMockedConfiguration();
$config->expects($this->atLeastOnce()) // Changed from once() to atLeastOnce()
->method('getId')
->willReturn(1);
$salesforceUser = [
'Email' => '[EMAIL]',
'UserPreferencesLightningExperiencePreferred' => true,
'CallCenterId' => '123',
'Id' => '456',
'ProfileId' => '789',
'CustomField' => 'CustomValue',
];
$customRules = [
['field' => 'CustomField', 'value' => 'CustomValue'],
];
$this->mockQueryBuilderAndHandler($userToSearch, [$salesforceUser]);
$user = $this->createMock(User::class);
$user->expects($this->once())
->method('getId')
->willReturn(123);
$this->mockTeamRepository($team, $salesforceUser, $user, true, $customRules);
$this->mockProfileRepository($user);
$serviceMock = $this->getServiceMock();
$serviceMock->team = $team;
$serviceMock->config = $config;
$profile = $serviceMock->syncProfiles($userToSearch);
$this->assertNull($profile);
}
public function testSyncProfilesWithCustomValidationFailing(): void
{
$userToSearch = null;
$team = $this->createMock(Team::class);
$config = $this->createMockedConfiguration();
$salesforceUser = [
'Email' => '[EMAIL]',
'UserPreferencesLightningExperiencePreferred' => true,
'CallCenterId' => '123',
'Id' => '456',
'ProfileId' => '789',
'CustomField' => 'WrongValue',
];
$customRules = [
['field' => 'CustomField', 'value' => 'CustomValue'],
];
$this->mockQueryBuilderAndHandler($userToSearch, [$salesforceUser]);
$teamRepository = $this->getMockForAbstractClass(TeamRepository::class, [], '', false, true, true, ['findActiveTeamMemberByEmail', 'getTeamSetting']);
$teamSettings = $this->createMock(TeamSettings::class);
$teamSettings->method('getValueType')
->willReturn('array');
$teamSettings->method('getValue')
->willReturn(json_encode($customRules));
$teamRepository->expects($this->once())
->method('getTeamSetting')
->with($team, 'custom_profile_validation')
->willReturn($teamSettings);
app()->bind(TeamRepository::class, function () use ($teamRepository) {
return $teamRepository;
});
$profileRepository = $this->createMock(ProfileRepository::class);
$profileRepository->expects($this->never())
->method('updateOrCreateProfile');
$this->app->instance(ProfileRepository::class, $profileRepository);
$serviceMock = $this->getServiceMock();
$serviceMock->team = $team;
$serviceMock->config = $config;
$result = $serviceMock->syncProfiles($userToSearch);
$this->assertNull($result);
}
public function testGetContactRolesFromCrm(): void
{
$contactRoles = [
[
'Id' => '1',
'ContactId' => 'Contact1',
'OpportunityId' => 'Opportunity1',
'Opportunity' => ['OwnerId' => 'Owner1'],
'IsPrimary' => true,
'Role' => 'Decision Maker',
],
];
$expectedResponse = [
[
'id' => '1',
'contactId' => 'Contact1',
'opportunityId' => 'Opportunity1',
'ownerId' => 'Owner1',
'isPrimary' => true,
'role' => 'Decision Maker',
],
];
$this->bindQueryIterator($contactRoles);
$serviceMock = $this->getServiceMock();
$data = $serviceMock->getContactRolesFromCrm(now()->subDay());
$this->assertEquals($expectedResponse, $data);
}
public function testGetContactRolesFromCrmNoResult(): void
{
$this->bindQueryIterator([]);
$serviceMock = $this->getServiceMock();
$data = $serviceMock->getContactRolesFromCrm(now()->subDay());
$this->assertEquals([], $data);
}
public function testSyncContactRoles(): void
{
$contactRoles = [
[
'id' => '1',
'contactId' => 'Contact1',
'opportunityId' => 'Opportunity1',
'ownerId' => 'Owner1',
'isPrimary' => true,
'role' => 'Decision Maker',
],
];
app()->bind(ContactRoleRepository::class, function () {
$contactRoleRepository = $this->createMock(ContactRoleRepository::class);
$contactRoleRepository->expects($this->once())
->method('saveContactRoles');
return $contactRoleRepository;
});
$serviceMock = $this->getServiceMock([
'getContactRolesFromCrm',
'syncRemotelyDeletedContactRoles',
'syncContact',
'syncOpportunity',
]);
$config = $this->createMock(Configuration::class);
$hasMany = $this->createMock(HasManyExtended::class);
$hasMany->expects($this->exactly(2))
->method('where')
->willReturn($hasMany);
$hasMany->expects($this->exactly(2))
->method('first')
->willReturn(
$this->createMock(Contact::class),
$this->createMock(Opportunity::class)
);
$config->expects($this->once())
->method('contacts')
->willReturn($hasMany);
$config->expects($this->once())
->method('opportunities')
->willReturn($hasMany);
$serviceMock->config = $config;
$serviceMock->expects($this->once())
->method('getContactRolesFromCrm')
->willReturn($contactRoles);
$serviceMock->expects($this->once())
->method('syncRemotelyDeletedContactRoles');
$data = $serviceMock->syncContactRoles(now()->subDay());
$this->assertEquals(1, $data);
}
public function testSyncRemotelyDeletedContactRoles(): void
{
$contactRoles = [
[
'id' => '1',
'crm_provider_id' => '1',
],
];
app()->bind(QueryHandler::class, function () use ($contactRoles) {
$queryResults = new QueryResults($contactRoles, 1, true, null);
$handler = $this->createMock(QueryHandler::class);
$handler->method('queryDeleted')
->willReturn($queryResults);
return $handler;
});
app()->bind(ContactRoleRepository::class, function () {
$contactRoleRepository = $this->createMock(ContactRoleRepository::class);
$contactRoleRepository->expects($this->once())
->method('deleteContactRoles');
return $contactRoleRepository;
});
$serviceMock = $this->getServiceMock();
$serviceMock->team = $this->createMock(Team::class);
$data = $this->invokePrivateMethod('syncRemotelyDeletedContactRoles', $serviceMock, []);
$this->assertTrue($data);
}
private function bindQueryIterator(array $queryResult): void
{
/** @var Client $client */
$client = $this->createMock(Client::class);
$queryIterator = new QueryIterator(
$client,
new QueryResults($queryResult, 1, true, null)
);
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('query')
->willReturn($queryIterator);
return $handler;
});
}
public static function getOpportunitySortOrderDataProvider(): array
{
return [
'all open recently updated' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED, ['LastModifiedDate DESC', true]],
'all open recently created' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED, ['CreatedDate DESC', true]],
'all open oldest created' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED, ['CreatedDate ASC', true]],
'all recently updated' => [Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED, ['LastModifiedDate DESC', false]],
'default' => ['unknown', ['LastModifiedDate DESC', true]],
];
}
private function createMockedConfiguration(): Configuration
{
$config = $this->createMock(Configuration::class);
$profilesRelation = $this->getMockBuilder(\Illuminate\Database\Eloquent\Relations\HasMany::class)
->disableOriginalConstructor()
->onlyMethods(['get'])
->addMethods(['where', 'first'])
->getMock();
$profilesRelation->method('where')->willReturnSelf();
$profilesRelation->method('get')->willReturn(collect([]));
$profilesRelation->method('first')->willReturn(null);
$config->method('profiles')->willReturn($profilesRelation);
return $config;
}
private function getServiceMock(array $onlyMethods = []): MockObject&Service
{
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$this->createMock(Client::class),
$this->createMock(PayloadBuilder::class),
$this->createMock(Dispatcher::class),
$this->createMock(CountriesMap::class),
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods($onlyMethods)
->getMock();
$serviceMock->profile = $this->createMock(Profile::class);
return $serviceMock;
}
private function mockQueryBuilderAndHandler($userToSearch, $salesforceUsers): void
{
app()->bind(QueryBuilder::class, function () use ($userToSearch) {
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->expects($this->once())
->method('buildGetUsersQuery')
->with($userToSearch)
->willReturn('SELECT * FROM Users');
return $queryBuilder;
});
$queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults($salesforceUsers, count($salesforceUsers), true, null));
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('query')
->willReturn($queryIterator);
return $handler;
});
}
private function mockTeamRepository(
Team $team,
array $salesforceUser,
?User $user = null,
bool $userSearch = true,
array $customRules = []
): void {
$teamRepository = $this->getMockForAbstractClass(TeamRepository::class, [], '', false, true, true, ['findActiveTeamMemberByEmail', 'getTeamSetting']);
if ($userSearch) {
$teamRepository->expects($this->once())
->method('findActiveTeamMemberByEmail')
->with($team, $salesforceUser['Email'])
->willReturn($user);
}
$teamSettings = $this->createMock(TeamSettings::class);
$teamSettings->method('getValueType')
->willReturn('array');
$teamSettings->method('getValue')
->willReturn(json_encode($customRules));
$teamRepository->expects($this->once())
->method('getTeamSetting')
->with($team, 'custom_profile_validation')
->willReturn($teamSettings);
app()->bind(TeamRepository::class, function () use ($teamRepository) {
return $teamRepository;
});
}
private function mockProfileRepository(User $user): void
{
$profileRepository = $this->createMock(ProfileRepository::class);
$profileRepository->expects($this->once())
->method('updateOrCreateProfile')
->with(
$user,
[
'crm_configuration_id' => 1,
'crm_provider_id' => '456',
],
[
'user_id' => 123,
'edition' => Profile::EDITION_LIGHTNING,
'has_external_cti' => true,
'crm_profile_id' => '789',
]
)
->willReturn(new Profile());
$this->app->instance(ProfileRepository::class, $profileRepository);
}
public function testBuildEnhancedNoteDecodesHtmlEntities(): void
{
$service = $this->getServiceMock(['createRecord']);
$profile = new Profile();
$profile->setAttribute('crm_provider_id', 'owner-123');
$service->profile = $profile;
$service->expects($this->exactly(2))
->method('createRecord')
->willReturnOnConsecutiveCalls('note-id-123', 'link-id-456');
$bodyWithEntities = 'Welch's current challenges and Facebook's Club';
$result = $this->invokePrivateMethod('buildEnhancedNote', $service, [
'Test Title',
$bodyWithEntities,
'object-id-789',
]);
$this->assertEquals('note-id-123', $result);
}
public function testBuildEnhancedNoteSanitizesWithoutQuotes(): void
{
$service = $this->getServiceMock(['createRecord']);
$profile = new Profile();
$profile->setAttribute('crm_provider_id', 'owner-456');
$service->profile = $profile;
$service->expects($this->exactly(2))
->method('createRecord')
->willReturnCallback(function ($type, $data) {
if ($type === 'ContentNote') {
$decoded = base64_decode($data['Content']);
$this->assertStringContainsString("Welch's", $decoded);
$this->assertStringNotContainsString(''', $decoded);
$this->assertStringNotContainsString('&#039;', $decoded);
$this->assertStringContainsString('<script>', $decoded);
return 'note-id-456';
}
return 'link-id-789';
});
$bodyWithMixedContent = "Welch's and <script>alert('xss')</script>";
$result = $this->invokePrivateMethod('buildEnhancedNote', $service, [
'Test Title',
$bodyWithMixedContent,
'object-id-123',
]);
$this->assertEquals('note-id-456', $result);
}
public function testBuildEnhancedNoteConvertsLineBreaks(): void
{
$service = $this->getServiceMock(['createRecord']);
$profile = new Profile();
$profile->setAttribute('crm_provider_id', 'owner-789');
$service->profile = $profile;
$service->expects($this->exactly(2))
->method('createRecord')
->willReturnCallback(function ($type, $data) {
if ($type === 'ContentNote') {
$decoded = base64_decode($data['Content']);
$this->assertStringContainsString('<br>', $decoded);
$this->assertStringNotContainsString('<br />', $decoded);
return 'note-id-789';
}
return 'link-id-012';
});
$bodyWithLineBreaks = "Line 1\nLine 2\nLine 3";
$result = $this->invokePrivateMethod('buildEnhancedNote', $service, [
'Test Title',
$bodyWithLineBreaks,
'object-id-456',
]);
$this->assertEquals('note-id-789', $result);
}
public function testBuildEnhancedNoteHandlesComplexScenario(): void
{
$service = $this->getServiceMock(['createRecord']);
$profile = new Profile();
$profile->setAttribute('crm_provider_id', 'owner-complex');
$service->profile = $profile;
$service->expects($this->exactly(2))
->method('createRecord')
->willReturnCallback(function ($type, $data) {
if ($type === 'ContentNote') {
$decoded = base64_decode($data['Content']);
$this->assertStringContainsString("Welch's", $decoded);
$this->assertStringContainsString("Facebook's Club", $decoded);
$this->assertStringContainsString("Arctics'", $decoded);
$this->assertStringNotContainsString(''', $decoded);
$this->assertStringNotContainsString('&#039;', $decoded);
$this->assertStringContainsString('<br>', $decoded);
$this->assertStringContainsString('<', $decoded);
$this->assertStringContainsString('>', $decoded);
return 'note-complex';
}
return 'link-complex';
});
$complexBody = "Summary:\n---------\nThe call focused on understanding Welch's current challenges and exploring how Arctics' Revenue Growth Management solutions could support their strategic goals.\n\n• John SMith discussed his role as a category advisor for Google and Facebook's Club, emphasizing the importance of market research and advising on product assortment.\n• Madona introduced Arctics' Virtual Shoppers AI, which simulates consumer <behavior> to optimize pricing and promotional strategies.";
$result = $this->invokePrivateMethod('buildEnhancedNote', $service, [
'Jiminny Transcription Summary',
$complexBody,
'task-id-001',
]);
$this->assertEquals('note-complex', $result);
}
public function testSyncRemotelyDeletedObjectsWithErrorHandlingSuccess(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$prospectPhotoPathService,
])
->onlyMethods(['syncRemotelyDeletedObjects'])
->getMock();
// Mock team
$team = $this->createMock(Team::class);
$team->method('getUuid')->willReturn('team-uuid-123');
$serviceMock->team = $team;
// Expect syncRemotelyDeletedObjects to be called once and succeed
$serviceMock->expects($this->once())
->method('syncRemotelyDeletedObjects')
->with(\Jiminny\Enums\CrmObject::ACCOUNT);
// Call the protected method using reflection
$reflection = new \ReflectionClass($serviceMock);
$method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');
$method->setAccessible(true);
// Should not throw any exceptions
$method->invoke($serviceMock, \Jiminny\Enums\CrmObject::ACCOUNT);
$this->assertTrue(true); // Test completed successfully
}
public function testSyncRemotelyDeletedObjectsWithErrorHandlingFailure(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$prospectPhotoPathService,
])
->onlyMethods(['syncRemotelyDeletedObjects'])
->getMock();
// Mock team
$team = $this->createMock(Team::class);
$team->method('getUuid')->willReturn('team-uuid-456');
$serviceMock->team = $team;
// Mock logger to verify warning is logged
$logger = $this->createMock(\Psr\Log\LoggerInterface::class);
// Use reflection to set the protected logger property
$reflection = new \ReflectionClass($serviceMock);
$loggerProperty = $reflection->getProperty('logger');
$loggerProperty->setAccessible(true);
$loggerProperty->setValue($serviceMock, $logger);
$exception = new \Exception('Sync failed due to API error');
// Expect syncRemotelyDeletedObjects to throw an exception
$serviceMock->expects($this->once())
->method('syncRemotelyDeletedObjects')
->with(\Jiminny\Enums\CrmObject::CONTACT)
->willThrowException($exception);
// Expect warning to be logged with correct message and parameters
$logger->expects($this->once())
->method('warning')
->with(
'[Salesforce] Remotely deleted objects sync failed',
[
'objectType' => 'contact',
'teamId' => 'team-uuid-456',
'reason' => 'Sync failed due to API error',
]
);
// Call the protected method using reflection
$reflection = new \ReflectionClass($serviceMock);
$method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');
$method->setAccessible(true);
// Should not re-throw the exception, just log it
$method->invoke($serviceMock, \Jiminny\Enums\CrmObject::CONTACT);
$this->assertTrue(true); // Test completed successfully
}
public function testSyncRemotelyDeletedObjectsWithErrorHandlingWithLogParams(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$prospectPhotoPathService,
])
->onlyMethods(['syncRemotelyDeletedObjects'])
->getMock();
// Mock team
$team = $this->createMock(Team::class);
$team->method('getUuid')->willReturn('team-uuid-789');
$serviceMock->team = $team;
// Mock logger to verify warning is logged
$logger = $this->createMock(\Psr\Log\LoggerInterface::class);
// Use reflection to set the protected logger property
$loggerReflection = new \ReflectionClass($serviceMock);
$loggerProperty = $loggerReflection->getProperty('logger');
$loggerProperty->setAccessible(true);
$loggerProperty->setValue($serviceMock, $logger);
$exception = new \Exception('Network timeout');
// Expect syncRemotelyDeletedObjects to throw an exception
$serviceMock->expects($this->once())
->method('syncRemotelyDeletedObjects')
->with(\Jiminny\Enums\CrmObject::OPPORTUNITY)
->willThrowException($exception);
// Additional log parameters
$logParams = [
'syncType' => 'full',
'batchSize' => 100,
];
// Expect warning to be logged with merged parameters
$logger->expects($this->once())
->method('warning')
->with(
'[Salesforce] Remotely deleted objects sync failed',
[
'objectType' => 'opportunity',
'teamId' => 'team-uuid-789',
'reason' => 'Network timeout',
'syncType' => 'full',
'batchSize' => 100,
]
);
// Call the protected method using reflection
$reflection = new \ReflectionClass($serviceMock);
$method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');
$method->setAccessible(true);...
|
[{"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-20963-fix-import-on-deleted-entity, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.09607713,"height":0.025538707},"on_screen":true,"help_text":"Git Branch: JY-20963-fix-import-on-deleted-entity","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.85638297,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"ServiceTest","depth":6,"bounds":{"left":0.87167555,"top":0.019952115,"width":0.043882977,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'ServiceTest'","depth":6,"bounds":{"left":0.9155585,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'ServiceTest'","depth":6,"bounds":{"left":0.9268617,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.9381649,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.96609044,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9773936,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.9886968,"top":0.019952115,"width":0.011303186,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Built-in Preview","depth":4,"bounds":{"left":0.37101063,"top":0.13088587,"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":"Chrome","depth":4,"bounds":{"left":0.37965426,"top":0.13088587,"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":"Firefox","depth":4,"bounds":{"left":0.3882979,"top":0.13088587,"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":"Safari","depth":4,"bounds":{"left":0.39694148,"top":0.13088587,"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":"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":"4","depth":4,"bounds":{"left":0.35472074,"top":0.10055866,"width":0.007978723,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"32","depth":4,"bounds":{"left":0.36469415,"top":0.10055866,"width":0.010305851,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"168","depth":4,"bounds":{"left":0.37699467,"top":0.10055866,"width":0.011968086,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"bounds":{"left":0.39095744,"top":0.10055866,"width":0.00731383,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"28","depth":4,"bounds":{"left":0.40026596,"top":0.10055866,"width":0.009973404,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.4119016,"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.4192154,"top":0.09896249,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Services\\Crm\\Salesforce;\n\nuse Illuminate\\Database\\Eloquent\\Relations\\HasMany;\nuse Illuminate\\Events\\Dispatcher;\nuse Illuminate\\Support\\Facades\\Cache;\nuse Jiminny\\Component\\Country\\CountriesMap;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Enums\\CrmObject;\nuse Jiminny\\Exceptions\\InvalidArgumentException;\nuse Jiminny\\Exceptions\\NoResultsException;\nuse Jiminny\\Integrations\\PlaybookResolver;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Crm\\Configuration;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\TeamSettings;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\ContactRoleRepository;\nuse Jiminny\\Repositories\\Crm\\FieldDataRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\Salesforce\\Client;\nuse Jiminny\\Services\\Crm\\Salesforce\\PayloadBuilder;\nuse Jiminny\\Services\\Crm\\Salesforce\\QueryBuilder;\nuse Jiminny\\Services\\Crm\\Salesforce\\QueryHandler;\nuse Jiminny\\Services\\Crm\\Salesforce\\QueryIterator;\nuse Jiminny\\Services\\Crm\\Salesforce\\QueryResults;\nuse Jiminny\\Services\\Crm\\Salesforce\\Service;\nuse PHPUnit\\Framework\\MockObject\\MockObject;\nuse Tests\\TestCase;\nuse Tests\\Unit\\Traits\\TestPrivateMethod;\n\nclass ServiceTest extends TestCase\n{\n use TestPrivateMethod;\n\n public function testFetchAndAssociateRelatedActivity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $payloadBuilder->method('addCustomLogicFieldsPayload')\n ->willReturnCallback(function ($activity, $payload) {\n return $payload;\n });\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods(['fetchRelatedActivity', 'getPlaybook', 'getPlaybookCategory', 'updateRecord'])\n ->getMock();\n\n $serviceMock->expects($this->once())\n ->method('fetchRelatedActivity')\n ->willReturn([\n 'Id' => 'testId',\n 'Type' => null,\n 'OwnerId' => 'testerUser',\n 'Description' => 'Test description',\n ]);\n\n $user = $this->createMock(User::class);\n $team = $this->createMock(Team::class);\n $user->method('getAttribute')->with('team')->willReturn($team);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityField')->willReturn(null);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_EVENT);\n\n $serviceMock->expects($this->once())\n ->method('getPlaybook')\n ->with($user)\n ->willReturn($playbook);\n\n $serviceMock->expects($this->never())\n ->method('getPlaybookCategory');\n\n $serviceMock->expects($this->never())\n ->method('updateRecord');\n\n $fieldDataRepository = $this->createMock(FieldDataRepository::class);\n $fieldDataRepository->method('getActivityFieldData')->willReturn(collect([]));\n app()->instance(FieldDataRepository::class, $fieldDataRepository);\n\n $config = $this->createMock(Configuration::class);\n $profilesRelation = $this->getMockBuilder(\\Illuminate\\Database\\Eloquent\\Relations\\HasMany::class)\n ->disableOriginalConstructor()\n ->onlyMethods(['get'])\n ->addMethods(['where'])\n ->getMock();\n $profilesRelation->method('where')->willReturnSelf();\n $profilesRelation->method('get')->willReturn(collect([]));\n $config->method('profiles')->willReturn($profilesRelation);\n\n $serviceMock->config = $config;\n $serviceMock->profile = null;\n\n $actualStartTime = \\Carbon\\Carbon::now();\n\n $activity = $this->getMockBuilder(Activity::class)\n ->disableOriginalConstructor()\n ->onlyMethods(['update', 'hasProspect'])\n ->getMock();\n\n $activity->method('update')->willReturn(true);\n $activity->method('hasProspect')->willReturn(true);\n\n $activity->type = Activity::TYPE_CONFERENCE;\n $activity->provider = Activity::PROVIDER_TWILIO;\n $activity->lead_id = 1;\n $activity->user_id = 0;\n $activity->id_string = 'test-activity-id';\n $activity->user = $user;\n\n $activity->actual_start_time = $actualStartTime;\n $activity->uuid = 'c53d8320-f556-4cee-a2f8-5f232f454ca4';\n\n app()->bind(PlaybookResolver::class, function () use ($user) {\n $playbook = $this->createMock(Playbook::class);\n $playbookResolver = $this->createMock(PlaybookResolver::class);\n $playbookResolver->expects($this->once())\n ->method('resolvePlaybookByUser')\n ->with($user)\n ->willReturn($playbook);\n\n return $playbookResolver;\n });\n\n $data = $serviceMock->fetchAndAssociateRelatedActivity($activity);\n\n $this->assertInstanceOf(Activity::class, $data);\n $this->assertEquals(Activity::TYPE_CONFERENCE, $data->getType());\n $this->assertEquals($actualStartTime->getTimestamp(), $data->getActualStartTime()->getTimestamp());\n }\n\n public function testFetchAndAssociateRelatedActivitySkipsForTaskBasedPlaybook(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods(['fetchRelatedActivity', 'getPlaybook'])\n ->getMock();\n\n $user = $this->createMock(User::class);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n $playbook->method('getId')->willReturn(123);\n\n $serviceMock->expects($this->once())\n ->method('getPlaybook')\n ->with($user)\n ->willReturn($playbook);\n\n $serviceMock->expects($this->never())\n ->method('fetchRelatedActivity');\n\n $activity = $this->getMockBuilder(Activity::class)\n ->disableOriginalConstructor()\n ->onlyMethods(['hasProspect', 'getUuid'])\n ->getMock();\n\n $activity->method('hasProspect')->willReturn(true);\n $activity->method('getUuid')->willReturn('c53d8320-f556-4cee-a2f8-5f232f454ca4');\n $activity->type = Activity::TYPE_CONFERENCE;\n $activity->actual_start_time = \\Carbon\\Carbon::now();\n $activity->user = $user;\n\n $result = $serviceMock->fetchAndAssociateRelatedActivity($activity);\n\n $this->assertNull($result);\n }\n\n public function testFetchAndAssociateRelatedActivityReturnsNullForNonConference(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $serviceMock = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)\n );\n\n $activity = $this->getMockBuilder(Activity::class)\n ->disableOriginalConstructor()\n ->getMock();\n\n $activity->type = Activity::TYPE_SOFTPHONE;\n\n $result = $serviceMock->fetchAndAssociateRelatedActivity($activity);\n\n $this->assertNull($result);\n }\n\n public function testFetchAndAssociateRelatedActivityReturnsNullWhenNoStartTime(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $serviceMock = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)\n );\n\n\n $activity = $this->getMockBuilder(Activity::class)\n ->disableOriginalConstructor()\n ->getMock();\n\n $activity->type = Activity::TYPE_CONFERENCE;\n $activity->actual_start_time = null;\n $activity->scheduled_start_time = null;\n\n $result = $serviceMock->fetchAndAssociateRelatedActivity($activity);\n\n $this->assertNull($result);\n }\n\n public function testFetchAndAssociateRelatedActivityReturnsNullWhenNoProspect(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods(['getPlaybook'])\n ->getMock();\n\n $serviceMock->expects($this->never())\n ->method('getPlaybook');\n\n $activity = $this->getMockBuilder(Activity::class)\n ->disableOriginalConstructor()\n ->onlyMethods(['hasProspect', 'getUuid'])\n ->getMock();\n\n $activity->method('hasProspect')->willReturn(false);\n $activity->method('getUuid')->willReturn('c53d8320-f556-4cee-a2f8-5f232f454ca4');\n $activity->type = Activity::TYPE_CONFERENCE;\n $activity->actual_start_time = \\Carbon\\Carbon::now();\n\n $result = $serviceMock->fetchAndAssociateRelatedActivity($activity);\n\n $this->assertNull($result);\n }\n\n public function testMatchExactlyByEmail(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods([])\n ->getMock();\n\n $profile = new Profile();\n $profile->setAttribute('id', bin2hex(random_bytes(8)));\n $serviceMock->profile = $profile;\n\n $team = $this->createMock(Team::class);\n $serviceMock->team = $team;\n\n $data = $serviceMock->matchExactlyByEmail(bin2hex(random_bytes(8)) . 'test_email@testserver.com');\n\n $this->assertEquals(null, $data);\n }\n\n public function testMatchDomainFromEmail(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $queryIterator = $this->createMock(QueryIterator::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $config = $this->createMock(Configuration::class);\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('search')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods(['convertCrmData'])\n ->getMock();\n\n $profile = new Profile();\n $profile->account_fields = 'Field1, Field2, Field3';\n $serviceMock->profile = $profile;\n\n $serviceMock->expects($this->once())\n ->method('convertCrmData')\n ->willReturn(['test']);\n\n $this->app->bind(QueryBuilder::class, function () {\n $queryBuilder = $this->createMock(QueryBuilder::class);\n $queryBuilder->expects($this->once())\n ->method('buildMatchByDomainQuery')\n ->with('test_email@testserver.com')\n ->willReturn('FIND {test_email@testserver.com} IN ALL FIELDS RETURNING Account(Id)');\n\n return $queryBuilder;\n });\n\n $team = $this->createMock(Team::class);\n\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n\n $data = $serviceMock->matchByDomain('test_email@testserver.com');\n\n $this->assertEquals(['test'], $data);\n }\n\n public function testBuildTaskSearchFields(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)\n );\n\n $fields = $service->buildTaskSearchFields();\n\n $expectedFields = ['Id', 'WhoId', 'WhatId', 'AccountId'];\n\n $this->assertEquals($expectedFields, $fields);\n }\n\n public function testMapCrmObjects(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class),\n );\n\n $sampleTask = [\n 'WhoId' => '003sampleWhoId',\n 'AccountId' => 'sampleAccountId',\n 'WhatId' => 'sampleWhatId',\n ];\n\n $activityData = $service->mapCrmObjects($sampleTask);\n\n $expectedActivityData = [\n 'contact' => '003sampleWhoId',\n 'account' => 'sampleAccountId',\n 'opportunity' => 'sampleWhatId',\n ];\n\n $this->assertEquals($expectedActivityData, $activityData);\n }\n\n public function testGetInstalledAppVersion(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n $queryIterator = $this->createMock(QueryIterator::class);\n $queryIterator->expects($this->any())\n ->method('current')->willReturn([\n 'SubscriberPackageVersion' => [\n 'MajorVersion' => '1',\n 'MinorVersion' => '0',\n 'PatchVersion' => '1',\n 'BuildNumber' => '0',\n ],\n ]);\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('metadata')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods(array_diff(get_class_methods(Service::class), ['getInstalledAppVersion']))\n ->getMock();\n\n $version = $serviceMock->getInstalledAppVersion();\n\n $this->assertEquals('1010', $version);\n }\n\n public function testSyncProfiles(): void\n {\n $userToSearch = null;\n\n $team = $this->createMock(Team::class);\n $config = $this->createMockedConfiguration();\n $config->expects($this->once())\n ->method('getId')\n ->willReturn(1);\n\n $salesforceUser = [\n 'Email' => 'test@example.com',\n 'UserPreferencesLightningExperiencePreferred' => true,\n 'CallCenterId' => '123',\n 'Id' => '456',\n 'ProfileId' => '789',\n ];\n\n app()->bind(QueryBuilder::class, function () use ($userToSearch) {\n $queryBuilder = $this->createMock(QueryBuilder::class);\n $queryBuilder->expects($this->once())\n ->method('buildGetUsersQuery')\n ->with($userToSearch)\n ->willReturn('SELECT * FROM Users');\n\n return $queryBuilder;\n });\n\n $queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('query')\n ->with('SELECT * FROM Users')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n\n $user = $this->createMock(User::class);\n $user->expects($this->once())\n ->method('getId')\n ->willReturn(123);\n\n $this->mockTeamRepository($team, $salesforceUser, $user);\n\n $profileRepository = $this->createMock(ProfileRepository::class);\n $profileRepository->expects($this->once())\n ->method('updateOrCreateProfile')\n ->with(\n $user,\n [\n 'crm_configuration_id' => 1,\n 'crm_provider_id' => '456',\n ],\n [\n 'user_id' => 123,\n 'edition' => Profile::EDITION_LIGHTNING,\n 'has_external_cti' => true,\n 'crm_profile_id' => '789',\n ]\n )\n ->willReturn(new Profile());\n\n $this->app->instance(ProfileRepository::class, $profileRepository);\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n $result = $serviceMock->syncProfiles($userToSearch);\n\n $this->assertNull($result);\n }\n\n public function testSyncProfilesEmailIsNull(): void\n {\n $userToSearch = $this->createMock(User::class);\n\n $salesforceUser = [\n 'Email' => null,\n ];\n\n app()->bind(QueryBuilder::class, function () {\n $queryBuilder = $this->createMock(QueryBuilder::class);\n $queryBuilder->expects($this->once())\n ->method('buildGetUsersQuery')\n ->with(null)\n ->willReturn('SELECT * FROM Users');\n\n return $queryBuilder;\n });\n\n $queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->exactly(2))\n ->method('query')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n\n $team = $this->createMock(Team::class);\n $user = $this->createMock(User::class);\n $this->mockTeamRepository($team, $salesforceUser, $user, false);\n\n $config = $this->createMock(Configuration::class);\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n\n $profile = $serviceMock->syncProfiles(null);\n\n $this->assertNull($profile);\n }\n\n public function testSyncProfilesUserToSearchMatchesCurrentUser(): void\n {\n $userToSearch = $this->createMock(User::class);\n $userToSearch->expects($this->once())\n ->method('getId')\n ->willReturn(123);\n\n $team = $this->createMock(Team::class);\n $config = $this->createMock(Configuration::class);\n $config->expects($this->once())\n ->method('getId')\n ->willReturn(1);\n\n $salesforceUser = [\n 'Email' => 'test@example.com',\n 'UserPreferencesLightningExperiencePreferred' => true,\n 'CallCenterId' => '123',\n 'Id' => '456',\n 'ProfileId' => '789',\n ];\n\n app()->bind(QueryBuilder::class, function () use ($userToSearch) {\n $queryBuilder = $this->createMock(QueryBuilder::class);\n $queryBuilder->expects($this->once())\n ->method('buildGetUsersQuery')\n ->with($userToSearch)\n ->willReturn('SELECT * FROM Users');\n\n return $queryBuilder;\n });\n\n $queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('query')\n ->with('SELECT * FROM Users')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n\n $user = $this->createMock(User::class);\n $user->expects($this->exactly(2))\n ->method('getId')\n ->willReturn(123);\n\n $this->mockTeamRepository($team, $salesforceUser, $user);\n\n $profileRepository = $this->createMock(ProfileRepository::class);\n $profileRepository->expects($this->once())\n ->method('updateOrCreateProfile')\n ->with(\n $user,\n [\n 'crm_configuration_id' => 1,\n 'crm_provider_id' => '456',\n ],\n [\n 'user_id' => 123,\n 'edition' => Profile::EDITION_LIGHTNING,\n 'has_external_cti' => true,\n 'crm_profile_id' => '789',\n ]\n )\n ->willReturn(new Profile());\n\n $this->app->instance(ProfileRepository::class, $profileRepository);\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n $profile = $serviceMock->syncProfiles($userToSearch);\n\n $this->assertInstanceOf(Profile::class, $profile);\n }\n\n public function testSyncProfilesWithCustomValidation(): void\n {\n $userToSearch = null;\n\n $team = $this->createMock(Team::class);\n $config = $this->createMockedConfiguration();\n $config->expects($this->atLeastOnce()) // Changed from once() to atLeastOnce()\n ->method('getId')\n ->willReturn(1);\n\n $salesforceUser = [\n 'Email' => 'test@example.com',\n 'UserPreferencesLightningExperiencePreferred' => true,\n 'CallCenterId' => '123',\n 'Id' => '456',\n 'ProfileId' => '789',\n 'CustomField' => 'CustomValue',\n ];\n\n $customRules = [\n ['field' => 'CustomField', 'value' => 'CustomValue'],\n ];\n\n $this->mockQueryBuilderAndHandler($userToSearch, [$salesforceUser]);\n\n $user = $this->createMock(User::class);\n $user->expects($this->once())\n ->method('getId')\n ->willReturn(123);\n\n $this->mockTeamRepository($team, $salesforceUser, $user, true, $customRules);\n\n $this->mockProfileRepository($user);\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n $profile = $serviceMock->syncProfiles($userToSearch);\n\n $this->assertNull($profile);\n }\n\n public function testSyncProfilesWithCustomValidationFailing(): void\n {\n $userToSearch = null;\n\n $team = $this->createMock(Team::class);\n $config = $this->createMockedConfiguration();\n\n $salesforceUser = [\n 'Email' => 'test@example.com',\n 'UserPreferencesLightningExperiencePreferred' => true,\n 'CallCenterId' => '123',\n 'Id' => '456',\n 'ProfileId' => '789',\n 'CustomField' => 'WrongValue',\n ];\n\n $customRules = [\n ['field' => 'CustomField', 'value' => 'CustomValue'],\n ];\n\n $this->mockQueryBuilderAndHandler($userToSearch, [$salesforceUser]);\n\n $teamRepository = $this->getMockForAbstractClass(TeamRepository::class, [], '', false, true, true, ['findActiveTeamMemberByEmail', 'getTeamSetting']);\n\n $teamSettings = $this->createMock(TeamSettings::class);\n $teamSettings->method('getValueType')\n ->willReturn('array');\n\n $teamSettings->method('getValue')\n ->willReturn(json_encode($customRules));\n\n $teamRepository->expects($this->once())\n ->method('getTeamSetting')\n ->with($team, 'custom_profile_validation')\n ->willReturn($teamSettings);\n\n app()->bind(TeamRepository::class, function () use ($teamRepository) {\n return $teamRepository;\n });\n\n $profileRepository = $this->createMock(ProfileRepository::class);\n $profileRepository->expects($this->never())\n ->method('updateOrCreateProfile');\n\n $this->app->instance(ProfileRepository::class, $profileRepository);\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n $result = $serviceMock->syncProfiles($userToSearch);\n\n $this->assertNull($result);\n }\n\n public function testGetContactRolesFromCrm(): void\n {\n $contactRoles = [\n [\n 'Id' => '1',\n 'ContactId' => 'Contact1',\n 'OpportunityId' => 'Opportunity1',\n 'Opportunity' => ['OwnerId' => 'Owner1'],\n 'IsPrimary' => true,\n 'Role' => 'Decision Maker',\n ],\n ];\n\n $expectedResponse = [\n [\n 'id' => '1',\n 'contactId' => 'Contact1',\n 'opportunityId' => 'Opportunity1',\n 'ownerId' => 'Owner1',\n 'isPrimary' => true,\n 'role' => 'Decision Maker',\n ],\n ];\n\n $this->bindQueryIterator($contactRoles);\n\n $serviceMock = $this->getServiceMock();\n\n $data = $serviceMock->getContactRolesFromCrm(now()->subDay());\n\n $this->assertEquals($expectedResponse, $data);\n }\n\n public function testGetContactRolesFromCrmNoResult(): void\n {\n $this->bindQueryIterator([]);\n\n $serviceMock = $this->getServiceMock();\n\n $data = $serviceMock->getContactRolesFromCrm(now()->subDay());\n\n $this->assertEquals([], $data);\n }\n\n public function testSyncContactRoles(): void\n {\n $contactRoles = [\n [\n 'id' => '1',\n 'contactId' => 'Contact1',\n 'opportunityId' => 'Opportunity1',\n 'ownerId' => 'Owner1',\n 'isPrimary' => true,\n 'role' => 'Decision Maker',\n ],\n ];\n\n app()->bind(ContactRoleRepository::class, function () {\n $contactRoleRepository = $this->createMock(ContactRoleRepository::class);\n $contactRoleRepository->expects($this->once())\n ->method('saveContactRoles');\n\n return $contactRoleRepository;\n });\n\n $serviceMock = $this->getServiceMock([\n 'getContactRolesFromCrm',\n 'syncRemotelyDeletedContactRoles',\n 'syncContact',\n 'syncOpportunity',\n ]);\n\n $config = $this->createMock(Configuration::class);\n $hasMany = $this->createMock(HasManyExtended::class);\n $hasMany->expects($this->exactly(2))\n ->method('where')\n ->willReturn($hasMany);\n\n $hasMany->expects($this->exactly(2))\n ->method('first')\n ->willReturn(\n $this->createMock(Contact::class),\n $this->createMock(Opportunity::class)\n );\n\n $config->expects($this->once())\n ->method('contacts')\n ->willReturn($hasMany);\n $config->expects($this->once())\n ->method('opportunities')\n ->willReturn($hasMany);\n\n $serviceMock->config = $config;\n\n $serviceMock->expects($this->once())\n ->method('getContactRolesFromCrm')\n ->willReturn($contactRoles);\n\n $serviceMock->expects($this->once())\n ->method('syncRemotelyDeletedContactRoles');\n\n $data = $serviceMock->syncContactRoles(now()->subDay());\n\n $this->assertEquals(1, $data);\n }\n\n public function testSyncRemotelyDeletedContactRoles(): void\n {\n $contactRoles = [\n [\n 'id' => '1',\n 'crm_provider_id' => '1',\n ],\n ];\n\n app()->bind(QueryHandler::class, function () use ($contactRoles) {\n $queryResults = new QueryResults($contactRoles, 1, true, null);\n\n $handler = $this->createMock(QueryHandler::class);\n $handler->method('queryDeleted')\n ->willReturn($queryResults);\n\n return $handler;\n });\n\n app()->bind(ContactRoleRepository::class, function () {\n $contactRoleRepository = $this->createMock(ContactRoleRepository::class);\n $contactRoleRepository->expects($this->once())\n ->method('deleteContactRoles');\n\n return $contactRoleRepository;\n });\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $this->createMock(Team::class);\n\n $data = $this->invokePrivateMethod('syncRemotelyDeletedContactRoles', $serviceMock, []);\n\n $this->assertTrue($data);\n }\n\n private function bindQueryIterator(array $queryResult): void\n {\n /** @var Client $client */\n $client = $this->createMock(Client::class);\n $queryIterator = new QueryIterator(\n $client,\n new QueryResults($queryResult, 1, true, null)\n );\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('query')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n }\n\n public static function getOpportunitySortOrderDataProvider(): array\n {\n return [\n 'all open recently updated' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED, ['LastModifiedDate DESC', true]],\n 'all open recently created' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED, ['CreatedDate DESC', true]],\n 'all open oldest created' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED, ['CreatedDate ASC', true]],\n 'all recently updated' => [Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED, ['LastModifiedDate DESC', false]],\n 'default' => ['unknown', ['LastModifiedDate DESC', true]],\n ];\n }\n\n private function createMockedConfiguration(): Configuration\n {\n $config = $this->createMock(Configuration::class);\n $profilesRelation = $this->getMockBuilder(\\Illuminate\\Database\\Eloquent\\Relations\\HasMany::class)\n ->disableOriginalConstructor()\n ->onlyMethods(['get'])\n ->addMethods(['where', 'first'])\n ->getMock();\n $profilesRelation->method('where')->willReturnSelf();\n $profilesRelation->method('get')->willReturn(collect([]));\n $profilesRelation->method('first')->willReturn(null);\n $config->method('profiles')->willReturn($profilesRelation);\n\n return $config;\n }\n\n private function getServiceMock(array $onlyMethods = []): MockObject&Service\n {\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $this->createMock(Client::class),\n $this->createMock(PayloadBuilder::class),\n $this->createMock(Dispatcher::class),\n $this->createMock(CountriesMap::class),\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods($onlyMethods)\n ->getMock();\n\n $serviceMock->profile = $this->createMock(Profile::class);\n\n return $serviceMock;\n }\n\n private function mockQueryBuilderAndHandler($userToSearch, $salesforceUsers): void\n {\n app()->bind(QueryBuilder::class, function () use ($userToSearch) {\n $queryBuilder = $this->createMock(QueryBuilder::class);\n $queryBuilder->expects($this->once())\n ->method('buildGetUsersQuery')\n ->with($userToSearch)\n ->willReturn('SELECT * FROM Users');\n\n return $queryBuilder;\n });\n\n $queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults($salesforceUsers, count($salesforceUsers), true, null));\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('query')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n }\n\n private function mockTeamRepository(\n Team $team,\n array $salesforceUser,\n ?User $user = null,\n bool $userSearch = true,\n array $customRules = []\n ): void {\n $teamRepository = $this->getMockForAbstractClass(TeamRepository::class, [], '', false, true, true, ['findActiveTeamMemberByEmail', 'getTeamSetting']);\n\n if ($userSearch) {\n $teamRepository->expects($this->once())\n ->method('findActiveTeamMemberByEmail')\n ->with($team, $salesforceUser['Email'])\n ->willReturn($user);\n }\n\n $teamSettings = $this->createMock(TeamSettings::class);\n $teamSettings->method('getValueType')\n ->willReturn('array');\n\n $teamSettings->method('getValue')\n ->willReturn(json_encode($customRules));\n\n $teamRepository->expects($this->once())\n ->method('getTeamSetting')\n ->with($team, 'custom_profile_validation')\n ->willReturn($teamSettings);\n\n app()->bind(TeamRepository::class, function () use ($teamRepository) {\n return $teamRepository;\n });\n }\n\n private function mockProfileRepository(User $user): void\n {\n $profileRepository = $this->createMock(ProfileRepository::class);\n $profileRepository->expects($this->once())\n ->method('updateOrCreateProfile')\n ->with(\n $user,\n [\n 'crm_configuration_id' => 1,\n 'crm_provider_id' => '456',\n ],\n [\n 'user_id' => 123,\n 'edition' => Profile::EDITION_LIGHTNING,\n 'has_external_cti' => true,\n 'crm_profile_id' => '789',\n ]\n )\n ->willReturn(new Profile());\n\n $this->app->instance(ProfileRepository::class, $profileRepository);\n }\n\n public function testBuildEnhancedNoteDecodesHtmlEntities(): void\n {\n $service = $this->getServiceMock(['createRecord']);\n\n $profile = new Profile();\n $profile->setAttribute('crm_provider_id', 'owner-123');\n $service->profile = $profile;\n\n $service->expects($this->exactly(2))\n ->method('createRecord')\n ->willReturnOnConsecutiveCalls('note-id-123', 'link-id-456');\n\n $bodyWithEntities = 'Welch's current challenges and Facebook's Club';\n\n $result = $this->invokePrivateMethod('buildEnhancedNote', $service, [\n 'Test Title',\n $bodyWithEntities,\n 'object-id-789',\n ]);\n\n $this->assertEquals('note-id-123', $result);\n }\n\n public function testBuildEnhancedNoteSanitizesWithoutQuotes(): void\n {\n $service = $this->getServiceMock(['createRecord']);\n\n $profile = new Profile();\n $profile->setAttribute('crm_provider_id', 'owner-456');\n $service->profile = $profile;\n\n $service->expects($this->exactly(2))\n ->method('createRecord')\n ->willReturnCallback(function ($type, $data) {\n if ($type === 'ContentNote') {\n $decoded = base64_decode($data['Content']);\n $this->assertStringContainsString(\"Welch's\", $decoded);\n $this->assertStringNotContainsString(''', $decoded);\n $this->assertStringNotContainsString('&#039;', $decoded);\n $this->assertStringContainsString('<script>', $decoded);\n\n return 'note-id-456';\n }\n\n return 'link-id-789';\n });\n\n $bodyWithMixedContent = \"Welch's and <script>alert('xss')</script>\";\n\n $result = $this->invokePrivateMethod('buildEnhancedNote', $service, [\n 'Test Title',\n $bodyWithMixedContent,\n 'object-id-123',\n ]);\n\n $this->assertEquals('note-id-456', $result);\n }\n\n public function testBuildEnhancedNoteConvertsLineBreaks(): void\n {\n $service = $this->getServiceMock(['createRecord']);\n\n $profile = new Profile();\n $profile->setAttribute('crm_provider_id', 'owner-789');\n $service->profile = $profile;\n\n $service->expects($this->exactly(2))\n ->method('createRecord')\n ->willReturnCallback(function ($type, $data) {\n if ($type === 'ContentNote') {\n $decoded = base64_decode($data['Content']);\n $this->assertStringContainsString('<br>', $decoded);\n $this->assertStringNotContainsString('<br />', $decoded);\n\n return 'note-id-789';\n }\n\n return 'link-id-012';\n });\n\n $bodyWithLineBreaks = \"Line 1\\nLine 2\\nLine 3\";\n\n $result = $this->invokePrivateMethod('buildEnhancedNote', $service, [\n 'Test Title',\n $bodyWithLineBreaks,\n 'object-id-456',\n ]);\n\n $this->assertEquals('note-id-789', $result);\n }\n\n public function testBuildEnhancedNoteHandlesComplexScenario(): void\n {\n $service = $this->getServiceMock(['createRecord']);\n\n $profile = new Profile();\n $profile->setAttribute('crm_provider_id', 'owner-complex');\n $service->profile = $profile;\n\n $service->expects($this->exactly(2))\n ->method('createRecord')\n ->willReturnCallback(function ($type, $data) {\n if ($type === 'ContentNote') {\n $decoded = base64_decode($data['Content']);\n\n $this->assertStringContainsString(\"Welch's\", $decoded);\n $this->assertStringContainsString(\"Facebook's Club\", $decoded);\n $this->assertStringContainsString(\"Arctics'\", $decoded);\n $this->assertStringNotContainsString(''', $decoded);\n $this->assertStringNotContainsString('&#039;', $decoded);\n $this->assertStringContainsString('<br>', $decoded);\n $this->assertStringContainsString('<', $decoded);\n $this->assertStringContainsString('>', $decoded);\n\n return 'note-complex';\n }\n\n return 'link-complex';\n });\n\n $complexBody = \"Summary:\\n---------\\nThe call focused on understanding Welch's current challenges and exploring how Arctics' Revenue Growth Management solutions could support their strategic goals.\\n\\n• John SMith discussed his role as a category advisor for Google and Facebook's Club, emphasizing the importance of market research and advising on product assortment.\\n• Madona introduced Arctics' Virtual Shoppers AI, which simulates consumer <behavior> to optimize pricing and promotional strategies.\";\n\n $result = $this->invokePrivateMethod('buildEnhancedNote', $service, [\n 'Jiminny Transcription Summary',\n $complexBody,\n 'task-id-001',\n ]);\n\n $this->assertEquals('note-complex', $result);\n }\n\n public function testSyncRemotelyDeletedObjectsWithErrorHandlingSuccess(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['syncRemotelyDeletedObjects'])\n ->getMock();\n\n // Mock team\n $team = $this->createMock(Team::class);\n $team->method('getUuid')->willReturn('team-uuid-123');\n $serviceMock->team = $team;\n\n // Expect syncRemotelyDeletedObjects to be called once and succeed\n $serviceMock->expects($this->once())\n ->method('syncRemotelyDeletedObjects')\n ->with(\\Jiminny\\Enums\\CrmObject::ACCOUNT);\n\n // Call the protected method using reflection\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');\n $method->setAccessible(true);\n\n // Should not throw any exceptions\n $method->invoke($serviceMock, \\Jiminny\\Enums\\CrmObject::ACCOUNT);\n\n $this->assertTrue(true); // Test completed successfully\n }\n\n public function testSyncRemotelyDeletedObjectsWithErrorHandlingFailure(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['syncRemotelyDeletedObjects'])\n ->getMock();\n\n // Mock team\n $team = $this->createMock(Team::class);\n $team->method('getUuid')->willReturn('team-uuid-456');\n $serviceMock->team = $team;\n\n // Mock logger to verify warning is logged\n $logger = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n\n // Use reflection to set the protected logger property\n $reflection = new \\ReflectionClass($serviceMock);\n $loggerProperty = $reflection->getProperty('logger');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($serviceMock, $logger);\n\n $exception = new \\Exception('Sync failed due to API error');\n\n // Expect syncRemotelyDeletedObjects to throw an exception\n $serviceMock->expects($this->once())\n ->method('syncRemotelyDeletedObjects')\n ->with(\\Jiminny\\Enums\\CrmObject::CONTACT)\n ->willThrowException($exception);\n\n // Expect warning to be logged with correct message and parameters\n $logger->expects($this->once())\n ->method('warning')\n ->with(\n '[Salesforce] Remotely deleted objects sync failed',\n [\n 'objectType' => 'contact',\n 'teamId' => 'team-uuid-456',\n 'reason' => 'Sync failed due to API error',\n ]\n );\n\n // Call the protected method using reflection\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');\n $method->setAccessible(true);\n\n // Should not re-throw the exception, just log it\n $method->invoke($serviceMock, \\Jiminny\\Enums\\CrmObject::CONTACT);\n\n $this->assertTrue(true); // Test completed successfully\n }\n\n public function testSyncRemotelyDeletedObjectsWithErrorHandlingWithLogParams(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['syncRemotelyDeletedObjects'])\n ->getMock();\n\n // Mock team\n $team = $this->createMock(Team::class);\n $team->method('getUuid')->willReturn('team-uuid-789');\n $serviceMock->team = $team;\n\n // Mock logger to verify warning is logged\n $logger = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n\n // Use reflection to set the protected logger property\n $loggerReflection = new \\ReflectionClass($serviceMock);\n $loggerProperty = $loggerReflection->getProperty('logger');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($serviceMock, $logger);\n\n $exception = new \\Exception('Network timeout');\n\n // Expect syncRemotelyDeletedObjects to throw an exception\n $serviceMock->expects($this->once())\n ->method('syncRemotelyDeletedObjects')\n ->with(\\Jiminny\\Enums\\CrmObject::OPPORTUNITY)\n ->willThrowException($exception);\n\n // Additional log parameters\n $logParams = [\n 'syncType' => 'full',\n 'batchSize' => 100,\n ];\n\n // Expect warning to be logged with merged parameters\n $logger->expects($this->once())\n ->method('warning')\n ->with(\n '[Salesforce] Remotely deleted objects sync failed',\n [\n 'objectType' => 'opportunity',\n 'teamId' => 'team-uuid-789',\n 'reason' => 'Network timeout',\n 'syncType' => 'full',\n 'batchSize' => 100,\n ]\n );\n\n // Call the protected method using reflection\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');\n $method->setAccessible(true);\n\n // Should not re-throw the exception, just log it\n $method->invoke($serviceMock, \\Jiminny\\Enums\\CrmObject::OPPORTUNITY, $logParams);\n\n $this->assertTrue(true); // Test completed successfully\n }\n\n /**\n * @dataProvider crmObjectProvider\n */\n public function testSyncRemotelyDeletedObjectsWithErrorHandlingDifferentCrmObjects(\\Jiminny\\Enums\\CrmObject $crmObject): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['syncRemotelyDeletedObjects'])\n ->getMock();\n\n // Mock team\n $team = $this->createMock(Team::class);\n $team->method('getUuid')->willReturn('team-uuid-test');\n $serviceMock->team = $team;\n\n // Mock logger to verify warning is logged\n $logger = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n\n // Use reflection to set the protected logger property\n $loggerReflectionClass = new \\ReflectionClass($serviceMock);\n $loggerProperty = $loggerReflectionClass->getProperty('logger');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($serviceMock, $logger);\n\n $exception = new \\Exception('Test error');\n\n // Expect syncRemotelyDeletedObjects to throw an exception\n $serviceMock->expects($this->once())\n ->method('syncRemotelyDeletedObjects')\n ->with($crmObject)\n ->willThrowException($exception);\n\n // Expect warning to be logged with correct entity type\n $logger->expects($this->once())\n ->method('warning')\n ->with(\n '[Salesforce] Remotely deleted objects sync failed',\n [\n 'objectType' => $crmObject->value,\n 'teamId' => 'team-uuid-test',\n 'reason' => 'Test error',\n ]\n );\n\n // Call the protected method using reflection\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');\n $method->setAccessible(true);\n\n $method->invoke($serviceMock, $crmObject);\n\n $this->assertTrue(true); // Test completed successfully\n }\n\n public function testHandleObjectDeletionWithDeletedEntity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['deleteCrmObject'])\n ->getMock();\n\n $entity = $this->createMock(\\Jiminny\\Models\\Account::class);\n $crmData = ['IsDeleted' => true];\n\n $serviceMock->expects($this->once())\n ->method('deleteCrmObject')\n ->with($entity);\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('handleObjectDeletion');\n $method->setAccessible(true);\n\n $method->invoke($serviceMock, $entity, $crmData);\n }\n\n public function testHandleObjectDeletionWithNonDeletedEntity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['deleteCrmObject'])\n ->getMock();\n\n $entity = $this->createMock(\\Jiminny\\Models\\Contact::class);\n $crmData = ['IsDeleted' => false];\n\n $serviceMock->expects($this->never())\n ->method('deleteCrmObject');\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('handleObjectDeletion');\n $method->setAccessible(true);\n\n $method->invoke($serviceMock, $entity, $crmData);\n }\n\n public function testDeleteCrmObjectWithValidEntity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['dispatchDeleteCrmObjectJob'])\n ->getMock();\n\n $entity = $this->createMock(\\Jiminny\\Models\\Lead::class);\n $entity->expects($this->once())->method('delete');\n\n $serviceMock->expects($this->once())\n ->method('dispatchDeleteCrmObjectJob')\n ->with($entity);\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('deleteCrmObject');\n $method->setAccessible(true);\n\n $method->invoke($serviceMock, $entity);\n }\n\n public function testDeleteCrmObjectWithNullEntity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['dispatchDeleteCrmObjectJob'])\n ->getMock();\n\n $serviceMock->expects($this->never())\n ->method('dispatchDeleteCrmObjectJob');\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('deleteCrmObject');\n $method->setAccessible(true);\n\n $method->invoke($serviceMock, null);\n }\n\n public function testDispatchDeleteCrmObjectJobWithNullEntity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($service);\n $method = $reflection->getMethod('dispatchDeleteCrmObjectJob');\n $method->setAccessible(true);\n\n // Should return early without dispatching - no exception expected\n $method->invoke($service, null);\n\n $this->assertTrue(true); // Test completed successfully\n }\n\n public function testDispatchDeleteCrmObjectJobWithUnsupportedEntity(): void\n {\n $this->expectException(\\TypeError::class);\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n $unsupportedEntity = $this->createMock(\\stdClass::class);\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($service);\n $method = $reflection->getMethod('dispatchDeleteCrmObjectJob');\n\n // This will throw TypeError due to union type constraint\n $method->invoke($service, $unsupportedEntity);\n }\n\n public function testHandleEntityDeletionByProviderIdMethodExists(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n // Test that the method exists and is accessible via reflection\n $reflection = new \\ReflectionClass($service);\n $method = $reflection->getMethod('handleEntityDeletionByProviderId');\n $method->setAccessible(true);\n\n // Verify method exists and has correct parameters\n $this->assertTrue($method->isProtected());\n $this->assertEquals(2, $method->getNumberOfParameters());\n\n $parameters = $method->getParameters();\n $this->assertEquals('targetEntity', $parameters[0]->getName());\n $this->assertEquals('crmData', $parameters[1]->getName());\n }\n\n public function testSyncRemotelyDeletedObjectsWithNoResults(): void\n {\n // Create a real service instance to avoid mock property issues\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n // Mock queryHandler to throw NoResultsException\n $queryHandler = $this->createMock(QueryHandler::class);\n $queryHandler->expects($this->once())\n ->method('queryDeleted')\n ->with('Opportunity')\n ->willThrowException(new NoResultsException('No results'));\n\n // Set the queryHandler using reflection on the real service\n $reflection = new \\ReflectionClass($service);\n $queryHandlerProperty = $reflection->getProperty('queryHandler');\n $queryHandlerProperty->setAccessible(true);\n $queryHandlerProperty->setValue($service, $queryHandler);\n\n $result = self::invokePrivateMethod('syncRemotelyDeletedObjects', $service, [CrmObject::OPPORTUNITY]);\n\n $this->assertFalse($result);\n }\n\n public function testSyncRemotelyDeletedObjectsWithEmptyResults(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n // Mock queryHandler to return empty results\n $queryResult = $this->createMock(QueryResults::class);\n $queryResult->method('getResults')->willReturn([]);\n\n $queryHandler = $this->createMock(QueryHandler::class);\n $queryHandler->expects($this->once())\n ->method('queryDeleted')\n ->with('Opportunity')\n ->willReturn($queryResult);\n\n // Set the queryHandler using reflection on the real service\n $reflection = new \\ReflectionClass($service);\n $queryHandlerProperty = $reflection->getProperty('queryHandler');\n $queryHandlerProperty->setAccessible(true);\n $queryHandlerProperty->setValue($service, $queryHandler);\n\n $result = self::invokePrivateMethod('syncRemotelyDeletedObjects', $service, [CrmObject::OPPORTUNITY]);\n\n $this->assertFalse($result);\n }\n\n public function testSyncRemotelyDeletedObjectsWithUnsupportedCrmObject(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n // Mock queryHandler to return some deleted objects so we reach the match statement\n $deletedObjects = [\n ['id' => 'task1'],\n ['id' => 'task2'],\n ];\n $queryResult = $this->createMock(QueryResults::class);\n $queryResult->method('getResults')->willReturn($deletedObjects);\n\n $queryHandler = $this->createMock(QueryHandler::class);\n $queryHandler->expects($this->once())\n ->method('queryDeleted')\n ->with('Task') // ucfirst('task') = 'Task'\n ->willReturn($queryResult);\n\n self::setPrivateProperty($service, 'queryHandler', $queryHandler);\n\n $this->expectException(InvalidArgumentException::class);\n $this->expectExceptionMessage('Unsupported CrmObject: task');\n\n self::invokePrivateMethod('syncRemotelyDeletedObjects', $service, [CrmObject::TASK]);\n }\n\n public static function crmObjectProvider(): array\n {\n return [\n 'Account' => [CrmObject::ACCOUNT],\n 'Contact' => [CrmObject::CONTACT],\n 'Lead' => [CrmObject::LEAD],\n 'Opportunity' => [CrmObject::OPPORTUNITY],\n ];\n }\n\n public function testVerifyTaskExistsReturnsTrue(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-123', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-123');\n $activity->method('getId')->willReturn(456);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-123', ['Id', 'IsDeleted'])\n ->willReturn(['Id' => 'task-123', 'IsDeleted' => false]);\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertTrue($result);\n }\n\n public function testVerifyTaskExistsReturnsTrueForEvent(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:event-123', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('event-123');\n $activity->method('getId')->willReturn(456);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_EVENT);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Event', 'event-123', ['Id', 'IsDeleted'])\n ->willReturn(['Id' => 'event-123', 'IsDeleted' => false]);\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertTrue($result);\n }\n\n public function testVerifyTaskExistsReturnsFalseWhenDeleted(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-456', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-456');\n $activity->method('getId')->willReturn(789);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-456', ['Id', 'IsDeleted'])\n ->willReturn(['Id' => 'task-456', 'IsDeleted' => true]);\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertFalse($result);\n }\n\n public function testVerifyTaskExistsReturnsFalseWhenNotFound(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-999', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-999');\n $activity->method('getId')->willReturn(999);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-999', ['Id', 'IsDeleted'])\n ->willThrowException(new \\Jiminny\\Exceptions\\HttpNotFoundException('Task not found'));\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertFalse($result);\n }\n\n public function testVerifyTaskExistsReturnsFalseWhenNoPlaybook(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-no-playbook', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-no-playbook');\n $activity->method('getId')->willReturn(111);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn(null);\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertFalse($result);\n }\n\n public function testVerifyTaskExistsThrowsExceptionForTransientErrors(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-error', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-error');\n $activity->method('getId')->willReturn(888);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-error', ['Id', 'IsDeleted'])\n ->willThrowException(new \\RuntimeException('Network timeout'));\n\n $this->expectException(\\RuntimeException::class);\n $this->expectExceptionMessage('Network timeout');\n\n $service->verifyTaskExists($activity);\n }\n\n public function testVerifyTaskExistsCachesResults(): void\n {\n $cachedValue = null;\n Cache::shouldReceive('remember')\n ->twice()\n ->with('crm_task_exists:123:task-cached', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(function ($key, $ttl, $callback) use (&$cachedValue) {\n if ($cachedValue === null) {\n $cachedValue = $callback();\n }\n\n return $cachedValue;\n });\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-cached');\n $activity->method('getId')->willReturn(555);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-cached', ['Id', 'IsDeleted'])\n ->willReturn(['Id' => 'task-cached', 'IsDeleted' => false]);\n\n $result1 = $service->verifyTaskExists($activity);\n $result2 = $service->verifyTaskExists($activity);\n\n $this->assertTrue($result1);\n $this->assertTrue($result2);\n }\n\n public function testVerifyTaskExistsReturnsFalseForHttpBadRequestException(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-400', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-400');\n $activity->method('getId')->willReturn(400);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-400', ['Id', 'IsDeleted'])\n ->willThrowException(new \\Jiminny\\Exceptions\\HttpBadRequestException('Bad request'));\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertFalse($result);\n }\n\n public function testImportOpportunitySkipsWhenNoProfileAndNoAccount(): void\n {\n $crmData = [\n 'Id' => 'SF-NO-USER-1',\n 'Name' => 'Test Opportunity',\n 'OwnerId' => 'owner-no-profile',\n // No AccountId\n ];\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(\\Illuminate\\Events\\Dispatcher::class); // ← ADD THIS\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n\n $service = new Service(\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService\n );\n\n $config = $this->createMock(Configuration::class);\n\n // Mock profiles relation returning null (no profile found)\n $profilesRelation = \\Mockery::mock(\\Illuminate\\Database\\Eloquent\\Relations\\HasMany::class);\n $profilesRelation->shouldReceive('where')->with('crm_provider_id', 'owner-no-profile')->andReturnSelf();\n $profilesRelation->shouldReceive('first')->andReturn(null);\n\n $config->expects($this->once())\n ->method('profiles')\n ->willReturn($profilesRelation);\n\n $team = $this->createMock(Team::class);\n $team->method('getId')->willReturn(1);\n\n $logger = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n $logger->expects($this->once())\n ->method('error')\n ->with(\n '[Salesforce] | Skip import, no user_id found',\n ['id' => 'SF-NO-USER-1']\n );\n\n $reflection = new \\ReflectionClass($service);\n\n $configProperty = $reflection->getProperty('config');\n $configProperty->setAccessible(true);\n $configProperty->setValue($service, $config);\n\n $teamProperty = $reflection->getProperty('team');\n $teamProperty->setAccessible(true);\n $teamProperty->setValue($service, $team);\n\n $loggerProperty = $reflection->getProperty('logger');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($service, $logger);\n\n // Initialize profile property to avoid \"must not be accessed before initialization\" error\n $profileProperty = $reflection->getProperty('profile');\n $profileProperty->setAccessible(true);\n $profileProperty->setValue($service, null);\n\n $result = self::invokePrivateMethod('importOpportunity', $service, [$crmData]);\n\n $this->assertNull($result);\n }\n\n public function testImportOpportunitySkipsWhenProfileNotFound(): void\n {\n $crmData = [\n 'Id' => 'SF-NO-USER-2',\n 'Name' => 'Test Opportunity',\n 'OwnerId' => 'owner-not-found',\n // No AccountId - avoid complex account processing\n ];\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $eventDispatcher = $this->createMock(\\Illuminate\\Events\\Dispatcher::class); // ← ADD THIS\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n\n\n $service = new Service(\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService\n );\n\n $config = $this->createMock(Configuration::class);\n\n // Mock profiles relation returning null (no profile found)\n $profilesRelation = \\Mockery::mock(\\Illuminate\\Database\\Eloquent\\Relations\\HasMany::class);\n $profilesRelation->shouldReceive('where')->with('crm_provider_id', 'owner-not-found')->andReturnSelf();\n $profilesRelation->shouldReceive('first')->andReturn(null);\n\n $config->expects($this->once())\n ->method('profiles')\n ->willReturn($profilesRelation);\n\n $team = $this->createMock(Team::class);\n $team->method('getId')->willReturn(1);\n\n $logger = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n $logger->expects($this->once())\n ->method('error')\n ->with(\n '[Salesforce] | Skip import, no user_id found',\n ['id' => 'SF-NO-USER-2']\n );\n\n $reflection = new \\ReflectionClass($service);\n\n $configProperty = $reflection->getProperty('config');\n $configProperty->setAccessible(true);\n $configProperty->setValue($service, $config);\n\n $teamProperty = $reflection->getProperty('team');\n $teamProperty->setAccessible(true);\n $teamProperty->setValue($service, $team);\n\n $loggerProperty = $reflection->getProperty('logger');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($service, $logger);\n\n // Initialize profile property\n $profileProperty = $reflection->getProperty('profile');\n $profileProperty->setAccessible(true);\n $profileProperty->setValue($service, null);\n\n $result = self::invokePrivateMethod('importOpportunity', $service, [$crmData]);\n\n $this->assertNull($result);\n }\n}\n\nclass HasManyExtended extends HasMany\n{\n public function where()\n {\n }\n\n public function first()\n {\n }\n}","depth":4,"bounds":{"left":0.16223404,"top":0.09736632,"width":0.3550532,"height":0.90263367},"on_screen":true,"value":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Services\\Crm\\Salesforce;\n\nuse Illuminate\\Database\\Eloquent\\Relations\\HasMany;\nuse Illuminate\\Events\\Dispatcher;\nuse Illuminate\\Support\\Facades\\Cache;\nuse Jiminny\\Component\\Country\\CountriesMap;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Enums\\CrmObject;\nuse Jiminny\\Exceptions\\InvalidArgumentException;\nuse Jiminny\\Exceptions\\NoResultsException;\nuse Jiminny\\Integrations\\PlaybookResolver;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Crm\\Configuration;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\TeamSettings;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\ContactRoleRepository;\nuse Jiminny\\Repositories\\Crm\\FieldDataRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\Salesforce\\Client;\nuse Jiminny\\Services\\Crm\\Salesforce\\PayloadBuilder;\nuse Jiminny\\Services\\Crm\\Salesforce\\QueryBuilder;\nuse Jiminny\\Services\\Crm\\Salesforce\\QueryHandler;\nuse Jiminny\\Services\\Crm\\Salesforce\\QueryIterator;\nuse Jiminny\\Services\\Crm\\Salesforce\\QueryResults;\nuse Jiminny\\Services\\Crm\\Salesforce\\Service;\nuse PHPUnit\\Framework\\MockObject\\MockObject;\nuse Tests\\TestCase;\nuse Tests\\Unit\\Traits\\TestPrivateMethod;\n\nclass ServiceTest extends TestCase\n{\n use TestPrivateMethod;\n\n public function testFetchAndAssociateRelatedActivity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $payloadBuilder->method('addCustomLogicFieldsPayload')\n ->willReturnCallback(function ($activity, $payload) {\n return $payload;\n });\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods(['fetchRelatedActivity', 'getPlaybook', 'getPlaybookCategory', 'updateRecord'])\n ->getMock();\n\n $serviceMock->expects($this->once())\n ->method('fetchRelatedActivity')\n ->willReturn([\n 'Id' => 'testId',\n 'Type' => null,\n 'OwnerId' => 'testerUser',\n 'Description' => 'Test description',\n ]);\n\n $user = $this->createMock(User::class);\n $team = $this->createMock(Team::class);\n $user->method('getAttribute')->with('team')->willReturn($team);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityField')->willReturn(null);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_EVENT);\n\n $serviceMock->expects($this->once())\n ->method('getPlaybook')\n ->with($user)\n ->willReturn($playbook);\n\n $serviceMock->expects($this->never())\n ->method('getPlaybookCategory');\n\n $serviceMock->expects($this->never())\n ->method('updateRecord');\n\n $fieldDataRepository = $this->createMock(FieldDataRepository::class);\n $fieldDataRepository->method('getActivityFieldData')->willReturn(collect([]));\n app()->instance(FieldDataRepository::class, $fieldDataRepository);\n\n $config = $this->createMock(Configuration::class);\n $profilesRelation = $this->getMockBuilder(\\Illuminate\\Database\\Eloquent\\Relations\\HasMany::class)\n ->disableOriginalConstructor()\n ->onlyMethods(['get'])\n ->addMethods(['where'])\n ->getMock();\n $profilesRelation->method('where')->willReturnSelf();\n $profilesRelation->method('get')->willReturn(collect([]));\n $config->method('profiles')->willReturn($profilesRelation);\n\n $serviceMock->config = $config;\n $serviceMock->profile = null;\n\n $actualStartTime = \\Carbon\\Carbon::now();\n\n $activity = $this->getMockBuilder(Activity::class)\n ->disableOriginalConstructor()\n ->onlyMethods(['update', 'hasProspect'])\n ->getMock();\n\n $activity->method('update')->willReturn(true);\n $activity->method('hasProspect')->willReturn(true);\n\n $activity->type = Activity::TYPE_CONFERENCE;\n $activity->provider = Activity::PROVIDER_TWILIO;\n $activity->lead_id = 1;\n $activity->user_id = 0;\n $activity->id_string = 'test-activity-id';\n $activity->user = $user;\n\n $activity->actual_start_time = $actualStartTime;\n $activity->uuid = 'c53d8320-f556-4cee-a2f8-5f232f454ca4';\n\n app()->bind(PlaybookResolver::class, function () use ($user) {\n $playbook = $this->createMock(Playbook::class);\n $playbookResolver = $this->createMock(PlaybookResolver::class);\n $playbookResolver->expects($this->once())\n ->method('resolvePlaybookByUser')\n ->with($user)\n ->willReturn($playbook);\n\n return $playbookResolver;\n });\n\n $data = $serviceMock->fetchAndAssociateRelatedActivity($activity);\n\n $this->assertInstanceOf(Activity::class, $data);\n $this->assertEquals(Activity::TYPE_CONFERENCE, $data->getType());\n $this->assertEquals($actualStartTime->getTimestamp(), $data->getActualStartTime()->getTimestamp());\n }\n\n public function testFetchAndAssociateRelatedActivitySkipsForTaskBasedPlaybook(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods(['fetchRelatedActivity', 'getPlaybook'])\n ->getMock();\n\n $user = $this->createMock(User::class);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n $playbook->method('getId')->willReturn(123);\n\n $serviceMock->expects($this->once())\n ->method('getPlaybook')\n ->with($user)\n ->willReturn($playbook);\n\n $serviceMock->expects($this->never())\n ->method('fetchRelatedActivity');\n\n $activity = $this->getMockBuilder(Activity::class)\n ->disableOriginalConstructor()\n ->onlyMethods(['hasProspect', 'getUuid'])\n ->getMock();\n\n $activity->method('hasProspect')->willReturn(true);\n $activity->method('getUuid')->willReturn('c53d8320-f556-4cee-a2f8-5f232f454ca4');\n $activity->type = Activity::TYPE_CONFERENCE;\n $activity->actual_start_time = \\Carbon\\Carbon::now();\n $activity->user = $user;\n\n $result = $serviceMock->fetchAndAssociateRelatedActivity($activity);\n\n $this->assertNull($result);\n }\n\n public function testFetchAndAssociateRelatedActivityReturnsNullForNonConference(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $serviceMock = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)\n );\n\n $activity = $this->getMockBuilder(Activity::class)\n ->disableOriginalConstructor()\n ->getMock();\n\n $activity->type = Activity::TYPE_SOFTPHONE;\n\n $result = $serviceMock->fetchAndAssociateRelatedActivity($activity);\n\n $this->assertNull($result);\n }\n\n public function testFetchAndAssociateRelatedActivityReturnsNullWhenNoStartTime(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $serviceMock = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)\n );\n\n\n $activity = $this->getMockBuilder(Activity::class)\n ->disableOriginalConstructor()\n ->getMock();\n\n $activity->type = Activity::TYPE_CONFERENCE;\n $activity->actual_start_time = null;\n $activity->scheduled_start_time = null;\n\n $result = $serviceMock->fetchAndAssociateRelatedActivity($activity);\n\n $this->assertNull($result);\n }\n\n public function testFetchAndAssociateRelatedActivityReturnsNullWhenNoProspect(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods(['getPlaybook'])\n ->getMock();\n\n $serviceMock->expects($this->never())\n ->method('getPlaybook');\n\n $activity = $this->getMockBuilder(Activity::class)\n ->disableOriginalConstructor()\n ->onlyMethods(['hasProspect', 'getUuid'])\n ->getMock();\n\n $activity->method('hasProspect')->willReturn(false);\n $activity->method('getUuid')->willReturn('c53d8320-f556-4cee-a2f8-5f232f454ca4');\n $activity->type = Activity::TYPE_CONFERENCE;\n $activity->actual_start_time = \\Carbon\\Carbon::now();\n\n $result = $serviceMock->fetchAndAssociateRelatedActivity($activity);\n\n $this->assertNull($result);\n }\n\n public function testMatchExactlyByEmail(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods([])\n ->getMock();\n\n $profile = new Profile();\n $profile->setAttribute('id', bin2hex(random_bytes(8)));\n $serviceMock->profile = $profile;\n\n $team = $this->createMock(Team::class);\n $serviceMock->team = $team;\n\n $data = $serviceMock->matchExactlyByEmail(bin2hex(random_bytes(8)) . 'test_email@testserver.com');\n\n $this->assertEquals(null, $data);\n }\n\n public function testMatchDomainFromEmail(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $queryIterator = $this->createMock(QueryIterator::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $config = $this->createMock(Configuration::class);\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('search')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods(['convertCrmData'])\n ->getMock();\n\n $profile = new Profile();\n $profile->account_fields = 'Field1, Field2, Field3';\n $serviceMock->profile = $profile;\n\n $serviceMock->expects($this->once())\n ->method('convertCrmData')\n ->willReturn(['test']);\n\n $this->app->bind(QueryBuilder::class, function () {\n $queryBuilder = $this->createMock(QueryBuilder::class);\n $queryBuilder->expects($this->once())\n ->method('buildMatchByDomainQuery')\n ->with('test_email@testserver.com')\n ->willReturn('FIND {test_email@testserver.com} IN ALL FIELDS RETURNING Account(Id)');\n\n return $queryBuilder;\n });\n\n $team = $this->createMock(Team::class);\n\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n\n $data = $serviceMock->matchByDomain('test_email@testserver.com');\n\n $this->assertEquals(['test'], $data);\n }\n\n public function testBuildTaskSearchFields(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)\n );\n\n $fields = $service->buildTaskSearchFields();\n\n $expectedFields = ['Id', 'WhoId', 'WhatId', 'AccountId'];\n\n $this->assertEquals($expectedFields, $fields);\n }\n\n public function testMapCrmObjects(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class),\n );\n\n $sampleTask = [\n 'WhoId' => '003sampleWhoId',\n 'AccountId' => 'sampleAccountId',\n 'WhatId' => 'sampleWhatId',\n ];\n\n $activityData = $service->mapCrmObjects($sampleTask);\n\n $expectedActivityData = [\n 'contact' => '003sampleWhoId',\n 'account' => 'sampleAccountId',\n 'opportunity' => 'sampleWhatId',\n ];\n\n $this->assertEquals($expectedActivityData, $activityData);\n }\n\n public function testGetInstalledAppVersion(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n $queryIterator = $this->createMock(QueryIterator::class);\n $queryIterator->expects($this->any())\n ->method('current')->willReturn([\n 'SubscriberPackageVersion' => [\n 'MajorVersion' => '1',\n 'MinorVersion' => '0',\n 'PatchVersion' => '1',\n 'BuildNumber' => '0',\n ],\n ]);\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('metadata')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods(array_diff(get_class_methods(Service::class), ['getInstalledAppVersion']))\n ->getMock();\n\n $version = $serviceMock->getInstalledAppVersion();\n\n $this->assertEquals('1010', $version);\n }\n\n public function testSyncProfiles(): void\n {\n $userToSearch = null;\n\n $team = $this->createMock(Team::class);\n $config = $this->createMockedConfiguration();\n $config->expects($this->once())\n ->method('getId')\n ->willReturn(1);\n\n $salesforceUser = [\n 'Email' => 'test@example.com',\n 'UserPreferencesLightningExperiencePreferred' => true,\n 'CallCenterId' => '123',\n 'Id' => '456',\n 'ProfileId' => '789',\n ];\n\n app()->bind(QueryBuilder::class, function () use ($userToSearch) {\n $queryBuilder = $this->createMock(QueryBuilder::class);\n $queryBuilder->expects($this->once())\n ->method('buildGetUsersQuery')\n ->with($userToSearch)\n ->willReturn('SELECT * FROM Users');\n\n return $queryBuilder;\n });\n\n $queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('query')\n ->with('SELECT * FROM Users')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n\n $user = $this->createMock(User::class);\n $user->expects($this->once())\n ->method('getId')\n ->willReturn(123);\n\n $this->mockTeamRepository($team, $salesforceUser, $user);\n\n $profileRepository = $this->createMock(ProfileRepository::class);\n $profileRepository->expects($this->once())\n ->method('updateOrCreateProfile')\n ->with(\n $user,\n [\n 'crm_configuration_id' => 1,\n 'crm_provider_id' => '456',\n ],\n [\n 'user_id' => 123,\n 'edition' => Profile::EDITION_LIGHTNING,\n 'has_external_cti' => true,\n 'crm_profile_id' => '789',\n ]\n )\n ->willReturn(new Profile());\n\n $this->app->instance(ProfileRepository::class, $profileRepository);\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n $result = $serviceMock->syncProfiles($userToSearch);\n\n $this->assertNull($result);\n }\n\n public function testSyncProfilesEmailIsNull(): void\n {\n $userToSearch = $this->createMock(User::class);\n\n $salesforceUser = [\n 'Email' => null,\n ];\n\n app()->bind(QueryBuilder::class, function () {\n $queryBuilder = $this->createMock(QueryBuilder::class);\n $queryBuilder->expects($this->once())\n ->method('buildGetUsersQuery')\n ->with(null)\n ->willReturn('SELECT * FROM Users');\n\n return $queryBuilder;\n });\n\n $queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->exactly(2))\n ->method('query')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n\n $team = $this->createMock(Team::class);\n $user = $this->createMock(User::class);\n $this->mockTeamRepository($team, $salesforceUser, $user, false);\n\n $config = $this->createMock(Configuration::class);\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n\n $profile = $serviceMock->syncProfiles(null);\n\n $this->assertNull($profile);\n }\n\n public function testSyncProfilesUserToSearchMatchesCurrentUser(): void\n {\n $userToSearch = $this->createMock(User::class);\n $userToSearch->expects($this->once())\n ->method('getId')\n ->willReturn(123);\n\n $team = $this->createMock(Team::class);\n $config = $this->createMock(Configuration::class);\n $config->expects($this->once())\n ->method('getId')\n ->willReturn(1);\n\n $salesforceUser = [\n 'Email' => 'test@example.com',\n 'UserPreferencesLightningExperiencePreferred' => true,\n 'CallCenterId' => '123',\n 'Id' => '456',\n 'ProfileId' => '789',\n ];\n\n app()->bind(QueryBuilder::class, function () use ($userToSearch) {\n $queryBuilder = $this->createMock(QueryBuilder::class);\n $queryBuilder->expects($this->once())\n ->method('buildGetUsersQuery')\n ->with($userToSearch)\n ->willReturn('SELECT * FROM Users');\n\n return $queryBuilder;\n });\n\n $queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('query')\n ->with('SELECT * FROM Users')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n\n $user = $this->createMock(User::class);\n $user->expects($this->exactly(2))\n ->method('getId')\n ->willReturn(123);\n\n $this->mockTeamRepository($team, $salesforceUser, $user);\n\n $profileRepository = $this->createMock(ProfileRepository::class);\n $profileRepository->expects($this->once())\n ->method('updateOrCreateProfile')\n ->with(\n $user,\n [\n 'crm_configuration_id' => 1,\n 'crm_provider_id' => '456',\n ],\n [\n 'user_id' => 123,\n 'edition' => Profile::EDITION_LIGHTNING,\n 'has_external_cti' => true,\n 'crm_profile_id' => '789',\n ]\n )\n ->willReturn(new Profile());\n\n $this->app->instance(ProfileRepository::class, $profileRepository);\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n $profile = $serviceMock->syncProfiles($userToSearch);\n\n $this->assertInstanceOf(Profile::class, $profile);\n }\n\n public function testSyncProfilesWithCustomValidation(): void\n {\n $userToSearch = null;\n\n $team = $this->createMock(Team::class);\n $config = $this->createMockedConfiguration();\n $config->expects($this->atLeastOnce()) // Changed from once() to atLeastOnce()\n ->method('getId')\n ->willReturn(1);\n\n $salesforceUser = [\n 'Email' => 'test@example.com',\n 'UserPreferencesLightningExperiencePreferred' => true,\n 'CallCenterId' => '123',\n 'Id' => '456',\n 'ProfileId' => '789',\n 'CustomField' => 'CustomValue',\n ];\n\n $customRules = [\n ['field' => 'CustomField', 'value' => 'CustomValue'],\n ];\n\n $this->mockQueryBuilderAndHandler($userToSearch, [$salesforceUser]);\n\n $user = $this->createMock(User::class);\n $user->expects($this->once())\n ->method('getId')\n ->willReturn(123);\n\n $this->mockTeamRepository($team, $salesforceUser, $user, true, $customRules);\n\n $this->mockProfileRepository($user);\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n $profile = $serviceMock->syncProfiles($userToSearch);\n\n $this->assertNull($profile);\n }\n\n public function testSyncProfilesWithCustomValidationFailing(): void\n {\n $userToSearch = null;\n\n $team = $this->createMock(Team::class);\n $config = $this->createMockedConfiguration();\n\n $salesforceUser = [\n 'Email' => 'test@example.com',\n 'UserPreferencesLightningExperiencePreferred' => true,\n 'CallCenterId' => '123',\n 'Id' => '456',\n 'ProfileId' => '789',\n 'CustomField' => 'WrongValue',\n ];\n\n $customRules = [\n ['field' => 'CustomField', 'value' => 'CustomValue'],\n ];\n\n $this->mockQueryBuilderAndHandler($userToSearch, [$salesforceUser]);\n\n $teamRepository = $this->getMockForAbstractClass(TeamRepository::class, [], '', false, true, true, ['findActiveTeamMemberByEmail', 'getTeamSetting']);\n\n $teamSettings = $this->createMock(TeamSettings::class);\n $teamSettings->method('getValueType')\n ->willReturn('array');\n\n $teamSettings->method('getValue')\n ->willReturn(json_encode($customRules));\n\n $teamRepository->expects($this->once())\n ->method('getTeamSetting')\n ->with($team, 'custom_profile_validation')\n ->willReturn($teamSettings);\n\n app()->bind(TeamRepository::class, function () use ($teamRepository) {\n return $teamRepository;\n });\n\n $profileRepository = $this->createMock(ProfileRepository::class);\n $profileRepository->expects($this->never())\n ->method('updateOrCreateProfile');\n\n $this->app->instance(ProfileRepository::class, $profileRepository);\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n $result = $serviceMock->syncProfiles($userToSearch);\n\n $this->assertNull($result);\n }\n\n public function testGetContactRolesFromCrm(): void\n {\n $contactRoles = [\n [\n 'Id' => '1',\n 'ContactId' => 'Contact1',\n 'OpportunityId' => 'Opportunity1',\n 'Opportunity' => ['OwnerId' => 'Owner1'],\n 'IsPrimary' => true,\n 'Role' => 'Decision Maker',\n ],\n ];\n\n $expectedResponse = [\n [\n 'id' => '1',\n 'contactId' => 'Contact1',\n 'opportunityId' => 'Opportunity1',\n 'ownerId' => 'Owner1',\n 'isPrimary' => true,\n 'role' => 'Decision Maker',\n ],\n ];\n\n $this->bindQueryIterator($contactRoles);\n\n $serviceMock = $this->getServiceMock();\n\n $data = $serviceMock->getContactRolesFromCrm(now()->subDay());\n\n $this->assertEquals($expectedResponse, $data);\n }\n\n public function testGetContactRolesFromCrmNoResult(): void\n {\n $this->bindQueryIterator([]);\n\n $serviceMock = $this->getServiceMock();\n\n $data = $serviceMock->getContactRolesFromCrm(now()->subDay());\n\n $this->assertEquals([], $data);\n }\n\n public function testSyncContactRoles(): void\n {\n $contactRoles = [\n [\n 'id' => '1',\n 'contactId' => 'Contact1',\n 'opportunityId' => 'Opportunity1',\n 'ownerId' => 'Owner1',\n 'isPrimary' => true,\n 'role' => 'Decision Maker',\n ],\n ];\n\n app()->bind(ContactRoleRepository::class, function () {\n $contactRoleRepository = $this->createMock(ContactRoleRepository::class);\n $contactRoleRepository->expects($this->once())\n ->method('saveContactRoles');\n\n return $contactRoleRepository;\n });\n\n $serviceMock = $this->getServiceMock([\n 'getContactRolesFromCrm',\n 'syncRemotelyDeletedContactRoles',\n 'syncContact',\n 'syncOpportunity',\n ]);\n\n $config = $this->createMock(Configuration::class);\n $hasMany = $this->createMock(HasManyExtended::class);\n $hasMany->expects($this->exactly(2))\n ->method('where')\n ->willReturn($hasMany);\n\n $hasMany->expects($this->exactly(2))\n ->method('first')\n ->willReturn(\n $this->createMock(Contact::class),\n $this->createMock(Opportunity::class)\n );\n\n $config->expects($this->once())\n ->method('contacts')\n ->willReturn($hasMany);\n $config->expects($this->once())\n ->method('opportunities')\n ->willReturn($hasMany);\n\n $serviceMock->config = $config;\n\n $serviceMock->expects($this->once())\n ->method('getContactRolesFromCrm')\n ->willReturn($contactRoles);\n\n $serviceMock->expects($this->once())\n ->method('syncRemotelyDeletedContactRoles');\n\n $data = $serviceMock->syncContactRoles(now()->subDay());\n\n $this->assertEquals(1, $data);\n }\n\n public function testSyncRemotelyDeletedContactRoles(): void\n {\n $contactRoles = [\n [\n 'id' => '1',\n 'crm_provider_id' => '1',\n ],\n ];\n\n app()->bind(QueryHandler::class, function () use ($contactRoles) {\n $queryResults = new QueryResults($contactRoles, 1, true, null);\n\n $handler = $this->createMock(QueryHandler::class);\n $handler->method('queryDeleted')\n ->willReturn($queryResults);\n\n return $handler;\n });\n\n app()->bind(ContactRoleRepository::class, function () {\n $contactRoleRepository = $this->createMock(ContactRoleRepository::class);\n $contactRoleRepository->expects($this->once())\n ->method('deleteContactRoles');\n\n return $contactRoleRepository;\n });\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $this->createMock(Team::class);\n\n $data = $this->invokePrivateMethod('syncRemotelyDeletedContactRoles', $serviceMock, []);\n\n $this->assertTrue($data);\n }\n\n private function bindQueryIterator(array $queryResult): void\n {\n /** @var Client $client */\n $client = $this->createMock(Client::class);\n $queryIterator = new QueryIterator(\n $client,\n new QueryResults($queryResult, 1, true, null)\n );\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('query')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n }\n\n public static function getOpportunitySortOrderDataProvider(): array\n {\n return [\n 'all open recently updated' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED, ['LastModifiedDate DESC', true]],\n 'all open recently created' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED, ['CreatedDate DESC', true]],\n 'all open oldest created' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED, ['CreatedDate ASC', true]],\n 'all recently updated' => [Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED, ['LastModifiedDate DESC', false]],\n 'default' => ['unknown', ['LastModifiedDate DESC', true]],\n ];\n }\n\n private function createMockedConfiguration(): Configuration\n {\n $config = $this->createMock(Configuration::class);\n $profilesRelation = $this->getMockBuilder(\\Illuminate\\Database\\Eloquent\\Relations\\HasMany::class)\n ->disableOriginalConstructor()\n ->onlyMethods(['get'])\n ->addMethods(['where', 'first'])\n ->getMock();\n $profilesRelation->method('where')->willReturnSelf();\n $profilesRelation->method('get')->willReturn(collect([]));\n $profilesRelation->method('first')->willReturn(null);\n $config->method('profiles')->willReturn($profilesRelation);\n\n return $config;\n }\n\n private function getServiceMock(array $onlyMethods = []): MockObject&Service\n {\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $this->createMock(Client::class),\n $this->createMock(PayloadBuilder::class),\n $this->createMock(Dispatcher::class),\n $this->createMock(CountriesMap::class),\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods($onlyMethods)\n ->getMock();\n\n $serviceMock->profile = $this->createMock(Profile::class);\n\n return $serviceMock;\n }\n\n private function mockQueryBuilderAndHandler($userToSearch, $salesforceUsers): void\n {\n app()->bind(QueryBuilder::class, function () use ($userToSearch) {\n $queryBuilder = $this->createMock(QueryBuilder::class);\n $queryBuilder->expects($this->once())\n ->method('buildGetUsersQuery')\n ->with($userToSearch)\n ->willReturn('SELECT * FROM Users');\n\n return $queryBuilder;\n });\n\n $queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults($salesforceUsers, count($salesforceUsers), true, null));\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('query')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n }\n\n private function mockTeamRepository(\n Team $team,\n array $salesforceUser,\n ?User $user = null,\n bool $userSearch = true,\n array $customRules = []\n ): void {\n $teamRepository = $this->getMockForAbstractClass(TeamRepository::class, [], '', false, true, true, ['findActiveTeamMemberByEmail', 'getTeamSetting']);\n\n if ($userSearch) {\n $teamRepository->expects($this->once())\n ->method('findActiveTeamMemberByEmail')\n ->with($team, $salesforceUser['Email'])\n ->willReturn($user);\n }\n\n $teamSettings = $this->createMock(TeamSettings::class);\n $teamSettings->method('getValueType')\n ->willReturn('array');\n\n $teamSettings->method('getValue')\n ->willReturn(json_encode($customRules));\n\n $teamRepository->expects($this->once())\n ->method('getTeamSetting')\n ->with($team, 'custom_profile_validation')\n ->willReturn($teamSettings);\n\n app()->bind(TeamRepository::class, function () use ($teamRepository) {\n return $teamRepository;\n });\n }\n\n private function mockProfileRepository(User $user): void\n {\n $profileRepository = $this->createMock(ProfileRepository::class);\n $profileRepository->expects($this->once())\n ->method('updateOrCreateProfile')\n ->with(\n $user,\n [\n 'crm_configuration_id' => 1,\n 'crm_provider_id' => '456',\n ],\n [\n 'user_id' => 123,\n 'edition' => Profile::EDITION_LIGHTNING,\n 'has_external_cti' => true,\n 'crm_profile_id' => '789',\n ]\n )\n ->willReturn(new Profile());\n\n $this->app->instance(ProfileRepository::class, $profileRepository);\n }\n\n public function testBuildEnhancedNoteDecodesHtmlEntities(): void\n {\n $service = $this->getServiceMock(['createRecord']);\n\n $profile = new Profile();\n $profile->setAttribute('crm_provider_id', 'owner-123');\n $service->profile = $profile;\n\n $service->expects($this->exactly(2))\n ->method('createRecord')\n ->willReturnOnConsecutiveCalls('note-id-123', 'link-id-456');\n\n $bodyWithEntities = 'Welch's current challenges and Facebook's Club';\n\n $result = $this->invokePrivateMethod('buildEnhancedNote', $service, [\n 'Test Title',\n $bodyWithEntities,\n 'object-id-789',\n ]);\n\n $this->assertEquals('note-id-123', $result);\n }\n\n public function testBuildEnhancedNoteSanitizesWithoutQuotes(): void\n {\n $service = $this->getServiceMock(['createRecord']);\n\n $profile = new Profile();\n $profile->setAttribute('crm_provider_id', 'owner-456');\n $service->profile = $profile;\n\n $service->expects($this->exactly(2))\n ->method('createRecord')\n ->willReturnCallback(function ($type, $data) {\n if ($type === 'ContentNote') {\n $decoded = base64_decode($data['Content']);\n $this->assertStringContainsString(\"Welch's\", $decoded);\n $this->assertStringNotContainsString(''', $decoded);\n $this->assertStringNotContainsString('&#039;', $decoded);\n $this->assertStringContainsString('<script>', $decoded);\n\n return 'note-id-456';\n }\n\n return 'link-id-789';\n });\n\n $bodyWithMixedContent = \"Welch's and <script>alert('xss')</script>\";\n\n $result = $this->invokePrivateMethod('buildEnhancedNote', $service, [\n 'Test Title',\n $bodyWithMixedContent,\n 'object-id-123',\n ]);\n\n $this->assertEquals('note-id-456', $result);\n }\n\n public function testBuildEnhancedNoteConvertsLineBreaks(): void\n {\n $service = $this->getServiceMock(['createRecord']);\n\n $profile = new Profile();\n $profile->setAttribute('crm_provider_id', 'owner-789');\n $service->profile = $profile;\n\n $service->expects($this->exactly(2))\n ->method('createRecord')\n ->willReturnCallback(function ($type, $data) {\n if ($type === 'ContentNote') {\n $decoded = base64_decode($data['Content']);\n $this->assertStringContainsString('<br>', $decoded);\n $this->assertStringNotContainsString('<br />', $decoded);\n\n return 'note-id-789';\n }\n\n return 'link-id-012';\n });\n\n $bodyWithLineBreaks = \"Line 1\\nLine 2\\nLine 3\";\n\n $result = $this->invokePrivateMethod('buildEnhancedNote', $service, [\n 'Test Title',\n $bodyWithLineBreaks,\n 'object-id-456',\n ]);\n\n $this->assertEquals('note-id-789', $result);\n }\n\n public function testBuildEnhancedNoteHandlesComplexScenario(): void\n {\n $service = $this->getServiceMock(['createRecord']);\n\n $profile = new Profile();\n $profile->setAttribute('crm_provider_id', 'owner-complex');\n $service->profile = $profile;\n\n $service->expects($this->exactly(2))\n ->method('createRecord')\n ->willReturnCallback(function ($type, $data) {\n if ($type === 'ContentNote') {\n $decoded = base64_decode($data['Content']);\n\n $this->assertStringContainsString(\"Welch's\", $decoded);\n $this->assertStringContainsString(\"Facebook's Club\", $decoded);\n $this->assertStringContainsString(\"Arctics'\", $decoded);\n $this->assertStringNotContainsString(''', $decoded);\n $this->assertStringNotContainsString('&#039;', $decoded);\n $this->assertStringContainsString('<br>', $decoded);\n $this->assertStringContainsString('<', $decoded);\n $this->assertStringContainsString('>', $decoded);\n\n return 'note-complex';\n }\n\n return 'link-complex';\n });\n\n $complexBody = \"Summary:\\n---------\\nThe call focused on understanding Welch's current challenges and exploring how Arctics' Revenue Growth Management solutions could support their strategic goals.\\n\\n• John SMith discussed his role as a category advisor for Google and Facebook's Club, emphasizing the importance of market research and advising on product assortment.\\n• Madona introduced Arctics' Virtual Shoppers AI, which simulates consumer <behavior> to optimize pricing and promotional strategies.\";\n\n $result = $this->invokePrivateMethod('buildEnhancedNote', $service, [\n 'Jiminny Transcription Summary',\n $complexBody,\n 'task-id-001',\n ]);\n\n $this->assertEquals('note-complex', $result);\n }\n\n public function testSyncRemotelyDeletedObjectsWithErrorHandlingSuccess(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['syncRemotelyDeletedObjects'])\n ->getMock();\n\n // Mock team\n $team = $this->createMock(Team::class);\n $team->method('getUuid')->willReturn('team-uuid-123');\n $serviceMock->team = $team;\n\n // Expect syncRemotelyDeletedObjects to be called once and succeed\n $serviceMock->expects($this->once())\n ->method('syncRemotelyDeletedObjects')\n ->with(\\Jiminny\\Enums\\CrmObject::ACCOUNT);\n\n // Call the protected method using reflection\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');\n $method->setAccessible(true);\n\n // Should not throw any exceptions\n $method->invoke($serviceMock, \\Jiminny\\Enums\\CrmObject::ACCOUNT);\n\n $this->assertTrue(true); // Test completed successfully\n }\n\n public function testSyncRemotelyDeletedObjectsWithErrorHandlingFailure(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['syncRemotelyDeletedObjects'])\n ->getMock();\n\n // Mock team\n $team = $this->createMock(Team::class);\n $team->method('getUuid')->willReturn('team-uuid-456');\n $serviceMock->team = $team;\n\n // Mock logger to verify warning is logged\n $logger = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n\n // Use reflection to set the protected logger property\n $reflection = new \\ReflectionClass($serviceMock);\n $loggerProperty = $reflection->getProperty('logger');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($serviceMock, $logger);\n\n $exception = new \\Exception('Sync failed due to API error');\n\n // Expect syncRemotelyDeletedObjects to throw an exception\n $serviceMock->expects($this->once())\n ->method('syncRemotelyDeletedObjects')\n ->with(\\Jiminny\\Enums\\CrmObject::CONTACT)\n ->willThrowException($exception);\n\n // Expect warning to be logged with correct message and parameters\n $logger->expects($this->once())\n ->method('warning')\n ->with(\n '[Salesforce] Remotely deleted objects sync failed',\n [\n 'objectType' => 'contact',\n 'teamId' => 'team-uuid-456',\n 'reason' => 'Sync failed due to API error',\n ]\n );\n\n // Call the protected method using reflection\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');\n $method->setAccessible(true);\n\n // Should not re-throw the exception, just log it\n $method->invoke($serviceMock, \\Jiminny\\Enums\\CrmObject::CONTACT);\n\n $this->assertTrue(true); // Test completed successfully\n }\n\n public function testSyncRemotelyDeletedObjectsWithErrorHandlingWithLogParams(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['syncRemotelyDeletedObjects'])\n ->getMock();\n\n // Mock team\n $team = $this->createMock(Team::class);\n $team->method('getUuid')->willReturn('team-uuid-789');\n $serviceMock->team = $team;\n\n // Mock logger to verify warning is logged\n $logger = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n\n // Use reflection to set the protected logger property\n $loggerReflection = new \\ReflectionClass($serviceMock);\n $loggerProperty = $loggerReflection->getProperty('logger');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($serviceMock, $logger);\n\n $exception = new \\Exception('Network timeout');\n\n // Expect syncRemotelyDeletedObjects to throw an exception\n $serviceMock->expects($this->once())\n ->method('syncRemotelyDeletedObjects')\n ->with(\\Jiminny\\Enums\\CrmObject::OPPORTUNITY)\n ->willThrowException($exception);\n\n // Additional log parameters\n $logParams = [\n 'syncType' => 'full',\n 'batchSize' => 100,\n ];\n\n // Expect warning to be logged with merged parameters\n $logger->expects($this->once())\n ->method('warning')\n ->with(\n '[Salesforce] Remotely deleted objects sync failed',\n [\n 'objectType' => 'opportunity',\n 'teamId' => 'team-uuid-789',\n 'reason' => 'Network timeout',\n 'syncType' => 'full',\n 'batchSize' => 100,\n ]\n );\n\n // Call the protected method using reflection\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');\n $method->setAccessible(true);\n\n // Should not re-throw the exception, just log it\n $method->invoke($serviceMock, \\Jiminny\\Enums\\CrmObject::OPPORTUNITY, $logParams);\n\n $this->assertTrue(true); // Test completed successfully\n }\n\n /**\n * @dataProvider crmObjectProvider\n */\n public function testSyncRemotelyDeletedObjectsWithErrorHandlingDifferentCrmObjects(\\Jiminny\\Enums\\CrmObject $crmObject): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['syncRemotelyDeletedObjects'])\n ->getMock();\n\n // Mock team\n $team = $this->createMock(Team::class);\n $team->method('getUuid')->willReturn('team-uuid-test');\n $serviceMock->team = $team;\n\n // Mock logger to verify warning is logged\n $logger = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n\n // Use reflection to set the protected logger property\n $loggerReflectionClass = new \\ReflectionClass($serviceMock);\n $loggerProperty = $loggerReflectionClass->getProperty('logger');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($serviceMock, $logger);\n\n $exception = new \\Exception('Test error');\n\n // Expect syncRemotelyDeletedObjects to throw an exception\n $serviceMock->expects($this->once())\n ->method('syncRemotelyDeletedObjects')\n ->with($crmObject)\n ->willThrowException($exception);\n\n // Expect warning to be logged with correct entity type\n $logger->expects($this->once())\n ->method('warning')\n ->with(\n '[Salesforce] Remotely deleted objects sync failed',\n [\n 'objectType' => $crmObject->value,\n 'teamId' => 'team-uuid-test',\n 'reason' => 'Test error',\n ]\n );\n\n // Call the protected method using reflection\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');\n $method->setAccessible(true);\n\n $method->invoke($serviceMock, $crmObject);\n\n $this->assertTrue(true); // Test completed successfully\n }\n\n public function testHandleObjectDeletionWithDeletedEntity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['deleteCrmObject'])\n ->getMock();\n\n $entity = $this->createMock(\\Jiminny\\Models\\Account::class);\n $crmData = ['IsDeleted' => true];\n\n $serviceMock->expects($this->once())\n ->method('deleteCrmObject')\n ->with($entity);\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('handleObjectDeletion');\n $method->setAccessible(true);\n\n $method->invoke($serviceMock, $entity, $crmData);\n }\n\n public function testHandleObjectDeletionWithNonDeletedEntity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['deleteCrmObject'])\n ->getMock();\n\n $entity = $this->createMock(\\Jiminny\\Models\\Contact::class);\n $crmData = ['IsDeleted' => false];\n\n $serviceMock->expects($this->never())\n ->method('deleteCrmObject');\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('handleObjectDeletion');\n $method->setAccessible(true);\n\n $method->invoke($serviceMock, $entity, $crmData);\n }\n\n public function testDeleteCrmObjectWithValidEntity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['dispatchDeleteCrmObjectJob'])\n ->getMock();\n\n $entity = $this->createMock(\\Jiminny\\Models\\Lead::class);\n $entity->expects($this->once())->method('delete');\n\n $serviceMock->expects($this->once())\n ->method('dispatchDeleteCrmObjectJob')\n ->with($entity);\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('deleteCrmObject');\n $method->setAccessible(true);\n\n $method->invoke($serviceMock, $entity);\n }\n\n public function testDeleteCrmObjectWithNullEntity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['dispatchDeleteCrmObjectJob'])\n ->getMock();\n\n $serviceMock->expects($this->never())\n ->method('dispatchDeleteCrmObjectJob');\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('deleteCrmObject');\n $method->setAccessible(true);\n\n $method->invoke($serviceMock, null);\n }\n\n public function testDispatchDeleteCrmObjectJobWithNullEntity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($service);\n $method = $reflection->getMethod('dispatchDeleteCrmObjectJob');\n $method->setAccessible(true);\n\n // Should return early without dispatching - no exception expected\n $method->invoke($service, null);\n\n $this->assertTrue(true); // Test completed successfully\n }\n\n public function testDispatchDeleteCrmObjectJobWithUnsupportedEntity(): void\n {\n $this->expectException(\\TypeError::class);\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n $unsupportedEntity = $this->createMock(\\stdClass::class);\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($service);\n $method = $reflection->getMethod('dispatchDeleteCrmObjectJob');\n\n // This will throw TypeError due to union type constraint\n $method->invoke($service, $unsupportedEntity);\n }\n\n public function testHandleEntityDeletionByProviderIdMethodExists(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n // Test that the method exists and is accessible via reflection\n $reflection = new \\ReflectionClass($service);\n $method = $reflection->getMethod('handleEntityDeletionByProviderId');\n $method->setAccessible(true);\n\n // Verify method exists and has correct parameters\n $this->assertTrue($method->isProtected());\n $this->assertEquals(2, $method->getNumberOfParameters());\n\n $parameters = $method->getParameters();\n $this->assertEquals('targetEntity', $parameters[0]->getName());\n $this->assertEquals('crmData', $parameters[1]->getName());\n }\n\n public function testSyncRemotelyDeletedObjectsWithNoResults(): void\n {\n // Create a real service instance to avoid mock property issues\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n // Mock queryHandler to throw NoResultsException\n $queryHandler = $this->createMock(QueryHandler::class);\n $queryHandler->expects($this->once())\n ->method('queryDeleted')\n ->with('Opportunity')\n ->willThrowException(new NoResultsException('No results'));\n\n // Set the queryHandler using reflection on the real service\n $reflection = new \\ReflectionClass($service);\n $queryHandlerProperty = $reflection->getProperty('queryHandler');\n $queryHandlerProperty->setAccessible(true);\n $queryHandlerProperty->setValue($service, $queryHandler);\n\n $result = self::invokePrivateMethod('syncRemotelyDeletedObjects', $service, [CrmObject::OPPORTUNITY]);\n\n $this->assertFalse($result);\n }\n\n public function testSyncRemotelyDeletedObjectsWithEmptyResults(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n // Mock queryHandler to return empty results\n $queryResult = $this->createMock(QueryResults::class);\n $queryResult->method('getResults')->willReturn([]);\n\n $queryHandler = $this->createMock(QueryHandler::class);\n $queryHandler->expects($this->once())\n ->method('queryDeleted')\n ->with('Opportunity')\n ->willReturn($queryResult);\n\n // Set the queryHandler using reflection on the real service\n $reflection = new \\ReflectionClass($service);\n $queryHandlerProperty = $reflection->getProperty('queryHandler');\n $queryHandlerProperty->setAccessible(true);\n $queryHandlerProperty->setValue($service, $queryHandler);\n\n $result = self::invokePrivateMethod('syncRemotelyDeletedObjects', $service, [CrmObject::OPPORTUNITY]);\n\n $this->assertFalse($result);\n }\n\n public function testSyncRemotelyDeletedObjectsWithUnsupportedCrmObject(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n // Mock queryHandler to return some deleted objects so we reach the match statement\n $deletedObjects = [\n ['id' => 'task1'],\n ['id' => 'task2'],\n ];\n $queryResult = $this->createMock(QueryResults::class);\n $queryResult->method('getResults')->willReturn($deletedObjects);\n\n $queryHandler = $this->createMock(QueryHandler::class);\n $queryHandler->expects($this->once())\n ->method('queryDeleted')\n ->with('Task') // ucfirst('task') = 'Task'\n ->willReturn($queryResult);\n\n self::setPrivateProperty($service, 'queryHandler', $queryHandler);\n\n $this->expectException(InvalidArgumentException::class);\n $this->expectExceptionMessage('Unsupported CrmObject: task');\n\n self::invokePrivateMethod('syncRemotelyDeletedObjects', $service, [CrmObject::TASK]);\n }\n\n public static function crmObjectProvider(): array\n {\n return [\n 'Account' => [CrmObject::ACCOUNT],\n 'Contact' => [CrmObject::CONTACT],\n 'Lead' => [CrmObject::LEAD],\n 'Opportunity' => [CrmObject::OPPORTUNITY],\n ];\n }\n\n public function testVerifyTaskExistsReturnsTrue(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-123', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-123');\n $activity->method('getId')->willReturn(456);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-123', ['Id', 'IsDeleted'])\n ->willReturn(['Id' => 'task-123', 'IsDeleted' => false]);\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertTrue($result);\n }\n\n public function testVerifyTaskExistsReturnsTrueForEvent(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:event-123', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('event-123');\n $activity->method('getId')->willReturn(456);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_EVENT);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Event', 'event-123', ['Id', 'IsDeleted'])\n ->willReturn(['Id' => 'event-123', 'IsDeleted' => false]);\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertTrue($result);\n }\n\n public function testVerifyTaskExistsReturnsFalseWhenDeleted(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-456', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-456');\n $activity->method('getId')->willReturn(789);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-456', ['Id', 'IsDeleted'])\n ->willReturn(['Id' => 'task-456', 'IsDeleted' => true]);\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertFalse($result);\n }\n\n public function testVerifyTaskExistsReturnsFalseWhenNotFound(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-999', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-999');\n $activity->method('getId')->willReturn(999);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-999', ['Id', 'IsDeleted'])\n ->willThrowException(new \\Jiminny\\Exceptions\\HttpNotFoundException('Task not found'));\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertFalse($result);\n }\n\n public function testVerifyTaskExistsReturnsFalseWhenNoPlaybook(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-no-playbook', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-no-playbook');\n $activity->method('getId')->willReturn(111);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn(null);\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertFalse($result);\n }\n\n public function testVerifyTaskExistsThrowsExceptionForTransientErrors(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-error', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-error');\n $activity->method('getId')->willReturn(888);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-error', ['Id', 'IsDeleted'])\n ->willThrowException(new \\RuntimeException('Network timeout'));\n\n $this->expectException(\\RuntimeException::class);\n $this->expectExceptionMessage('Network timeout');\n\n $service->verifyTaskExists($activity);\n }\n\n public function testVerifyTaskExistsCachesResults(): void\n {\n $cachedValue = null;\n Cache::shouldReceive('remember')\n ->twice()\n ->with('crm_task_exists:123:task-cached', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(function ($key, $ttl, $callback) use (&$cachedValue) {\n if ($cachedValue === null) {\n $cachedValue = $callback();\n }\n\n return $cachedValue;\n });\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-cached');\n $activity->method('getId')->willReturn(555);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-cached', ['Id', 'IsDeleted'])\n ->willReturn(['Id' => 'task-cached', 'IsDeleted' => false]);\n\n $result1 = $service->verifyTaskExists($activity);\n $result2 = $service->verifyTaskExists($activity);\n\n $this->assertTrue($result1);\n $this->assertTrue($result2);\n }\n\n public function testVerifyTaskExistsReturnsFalseForHttpBadRequestException(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-400', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-400');\n $activity->method('getId')->willReturn(400);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-400', ['Id', 'IsDeleted'])\n ->willThrowException(new \\Jiminny\\Exceptions\\HttpBadRequestException('Bad request'));\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertFalse($result);\n }\n\n public function testImportOpportunitySkipsWhenNoProfileAndNoAccount(): void\n {\n $crmData = [\n 'Id' => 'SF-NO-USER-1',\n 'Name' => 'Test Opportunity',\n 'OwnerId' => 'owner-no-profile',\n // No AccountId\n ];\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(\\Illuminate\\Events\\Dispatcher::class); // ← ADD THIS\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n\n $service = new Service(\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService\n );\n\n $config = $this->createMock(Configuration::class);\n\n // Mock profiles relation returning null (no profile found)\n $profilesRelation = \\Mockery::mock(\\Illuminate\\Database\\Eloquent\\Relations\\HasMany::class);\n $profilesRelation->shouldReceive('where')->with('crm_provider_id', 'owner-no-profile')->andReturnSelf();\n $profilesRelation->shouldReceive('first')->andReturn(null);\n\n $config->expects($this->once())\n ->method('profiles')\n ->willReturn($profilesRelation);\n\n $team = $this->createMock(Team::class);\n $team->method('getId')->willReturn(1);\n\n $logger = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n $logger->expects($this->once())\n ->method('error')\n ->with(\n '[Salesforce] | Skip import, no user_id found',\n ['id' => 'SF-NO-USER-1']\n );\n\n $reflection = new \\ReflectionClass($service);\n\n $configProperty = $reflection->getProperty('config');\n $configProperty->setAccessible(true);\n $configProperty->setValue($service, $config);\n\n $teamProperty = $reflection->getProperty('team');\n $teamProperty->setAccessible(true);\n $teamProperty->setValue($service, $team);\n\n $loggerProperty = $reflection->getProperty('logger');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($service, $logger);\n\n // Initialize profile property to avoid \"must not be accessed before initialization\" error\n $profileProperty = $reflection->getProperty('profile');\n $profileProperty->setAccessible(true);\n $profileProperty->setValue($service, null);\n\n $result = self::invokePrivateMethod('importOpportunity', $service, [$crmData]);\n\n $this->assertNull($result);\n }\n\n public function testImportOpportunitySkipsWhenProfileNotFound(): void\n {\n $crmData = [\n 'Id' => 'SF-NO-USER-2',\n 'Name' => 'Test Opportunity',\n 'OwnerId' => 'owner-not-found',\n // No AccountId - avoid complex account processing\n ];\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $eventDispatcher = $this->createMock(\\Illuminate\\Events\\Dispatcher::class); // ← ADD THIS\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n\n\n $service = new Service(\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService\n );\n\n $config = $this->createMock(Configuration::class);\n\n // Mock profiles relation returning null (no profile found)\n $profilesRelation = \\Mockery::mock(\\Illuminate\\Database\\Eloquent\\Relations\\HasMany::class);\n $profilesRelation->shouldReceive('where')->with('crm_provider_id', 'owner-not-found')->andReturnSelf();\n $profilesRelation->shouldReceive('first')->andReturn(null);\n\n $config->expects($this->once())\n ->method('profiles')\n ->willReturn($profilesRelation);\n\n $team = $this->createMock(Team::class);\n $team->method('getId')->willReturn(1);\n\n $logger = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n $logger->expects($this->once())\n ->method('error')\n ->with(\n '[Salesforce] | Skip import, no user_id found',\n ['id' => 'SF-NO-USER-2']\n );\n\n $reflection = new \\ReflectionClass($service);\n\n $configProperty = $reflection->getProperty('config');\n $configProperty->setAccessible(true);\n $configProperty->setValue($service, $config);\n\n $teamProperty = $reflection->getProperty('team');\n $teamProperty->setAccessible(true);\n $teamProperty->setValue($service, $team);\n\n $loggerProperty = $reflection->getProperty('logger');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($service, $logger);\n\n // Initialize profile property\n $profileProperty = $reflection->getProperty('profile');\n $profileProperty->setAccessible(true);\n $profileProperty->setValue($service, null);\n\n $result = self::invokePrivateMethod('importOpportunity', $service, [$crmData]);\n\n $this->assertNull($result);\n }\n}\n\nclass HasManyExtended extends HasMany\n{\n public function where()\n {\n }\n\n public function first()\n {\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"A","depth":4,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":5,"bounds":{"left":0.27027926,"top":1.0,"width":0.0039893617,"height":0.0},"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Select All","depth":4,"bounds":{"left":0.42652926,"top":0.14365523,"width":0.09009308,"height":0.0207502},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"320","depth":6,"bounds":{"left":0.5169548,"top":0.16520351,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"320","depth":7,"bounds":{"left":0.5169548,"top":0.16520351,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"5772","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"5772","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"46ea38f7-c940-395d-9c79-0329d30e9ab7","depth":6,"bounds":{"left":0.5169548,"top":0.1867518,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"46ea38f7-c940-395d-9c79-0329d30e9ab7","depth":7,"bounds":{"left":0.5169548,"top":0.1867518,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"2024-11-26 09:21:15","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"2024-11-26 09:21:15","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"401","depth":6,"bounds":{"left":0.5169548,"top":0.20830008,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"401","depth":7,"bounds":{"left":0.5169548,"top":0.20830008,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"5772","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"5772","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"<null>","depth":6,"bounds":{"left":0.5169548,"top":0.22984837,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"<null>","depth":7,"bounds":{"left":0.5169548,"top":0.22984837,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"5772","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"5772","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"salesforce","depth":6,"bounds":{"left":0.5169548,"top":0.25139666,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"salesforce","depth":7,"bounds":{"left":0.5169548,"top":0.25139666,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"2024-11-26 09:21:15","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"2024-11-26 09:21:15","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"enterprise","depth":6,"bounds":{"left":0.5169548,"top":0.27294493,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"enterprise","depth":7,"bounds":{"left":0.5169548,"top":0.27294493,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"2024-11-26 09:21:15","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"2024-11-26 09:21:15","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"SWE38","depth":6,"bounds":{"left":0.5169548,"top":0.29449323,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"SWE38","depth":7,"bounds":{"left":0.5169548,"top":0.29449323,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"2024-11-26 09:21:15","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"2024-11-26 09:21:15","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"0","depth":6,"bounds":{"left":0.5169548,"top":0.3160415,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"0","depth":7,"bounds":{"left":0.5169548,"top":0.3160415,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"5772","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"5772","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"50.0","depth":6,"bounds":{"left":0.5169548,"top":0.33758977,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"50.0","depth":7,"bounds":{"left":0.5169548,"top":0.33758977,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"2024-11-26 09:21:15","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"2024-11-26 09:21:15","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"01801","depth":6,"bounds":{"left":0.5169548,"top":0.35913807,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"01801","depth":7,"bounds":{"left":0.5169548,"top":0.35913807,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"2024-11-26 09:21:15","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"2024-11-26 09:21:15","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"1","depth":6,"bounds":{"left":0.5169548,"top":0.38068634,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"1","depth":7,"bounds":{"left":0.5169548,"top":0.38068634,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"5772","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"5772","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"1","depth":6,"bounds":{"left":0.5169548,"top":0.40223464,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"1","depth":7,"bounds":{"left":0.5169548,"top":0.40223464,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"5772","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"5772","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"1","depth":6,"bounds":{"left":0.5169548,"top":0.4237829,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"1","depth":7,"bounds":{"left":0.5169548,"top":0.4237829,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"5772","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"5772","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"00D20000000MdbgEAC","depth":6,"bounds":{"left":0.5169548,"top":0.44533122,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"}]...
|
-219575626138687605
|
-6520523286572803291
|
visual_change
|
accessibility
|
NULL
|
Project: faVsco.js, menu
JY-20963-fix-import-on-de Project: faVsco.js, menu
JY-20963-fix-import-on-deleted-entity, menu
Start Listening for PHP Debug Connections
ServiceTest
Run 'ServiceTest'
Debug 'ServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Built-in Preview
Chrome
Firefox
Safari
Sync Changes
Hide This Notification
Code changed:
Hide
4
32
168
1
28
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Tests\Unit\Services\Crm\Salesforce;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Events\Dispatcher;
use Illuminate\Support\Facades\Cache;
use Jiminny\Component\Country\CountriesMap;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Enums\CrmObject;
use Jiminny\Exceptions\InvalidArgumentException;
use Jiminny\Exceptions\NoResultsException;
use Jiminny\Integrations\PlaybookResolver;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Playbook;
use Jiminny\Models\Team;
use Jiminny\Models\TeamSettings;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\ContactRoleRepository;
use Jiminny\Repositories\Crm\FieldDataRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\Salesforce\Client;
use Jiminny\Services\Crm\Salesforce\PayloadBuilder;
use Jiminny\Services\Crm\Salesforce\QueryBuilder;
use Jiminny\Services\Crm\Salesforce\QueryHandler;
use Jiminny\Services\Crm\Salesforce\QueryIterator;
use Jiminny\Services\Crm\Salesforce\QueryResults;
use Jiminny\Services\Crm\Salesforce\Service;
use PHPUnit\Framework\MockObject\MockObject;
use Tests\TestCase;
use Tests\Unit\Traits\TestPrivateMethod;
class ServiceTest extends TestCase
{
use TestPrivateMethod;
public function testFetchAndAssociateRelatedActivity(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$payloadBuilder->method('addCustomLogicFieldsPayload')
->willReturnCallback(function ($activity, $payload) {
return $payload;
});
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods(['fetchRelatedActivity', 'getPlaybook', 'getPlaybookCategory', 'updateRecord'])
->getMock();
$serviceMock->expects($this->once())
->method('fetchRelatedActivity')
->willReturn([
'Id' => 'testId',
'Type' => null,
'OwnerId' => 'testerUser',
'Description' => 'Test description',
]);
$user = $this->createMock(User::class);
$team = $this->createMock(Team::class);
$user->method('getAttribute')->with('team')->willReturn($team);
$playbook = $this->createMock(Playbook::class);
$playbook->method('getActivityField')->willReturn(null);
$playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_EVENT);
$serviceMock->expects($this->once())
->method('getPlaybook')
->with($user)
->willReturn($playbook);
$serviceMock->expects($this->never())
->method('getPlaybookCategory');
$serviceMock->expects($this->never())
->method('updateRecord');
$fieldDataRepository = $this->createMock(FieldDataRepository::class);
$fieldDataRepository->method('getActivityFieldData')->willReturn(collect([]));
app()->instance(FieldDataRepository::class, $fieldDataRepository);
$config = $this->createMock(Configuration::class);
$profilesRelation = $this->getMockBuilder(\Illuminate\Database\Eloquent\Relations\HasMany::class)
->disableOriginalConstructor()
->onlyMethods(['get'])
->addMethods(['where'])
->getMock();
$profilesRelation->method('where')->willReturnSelf();
$profilesRelation->method('get')->willReturn(collect([]));
$config->method('profiles')->willReturn($profilesRelation);
$serviceMock->config = $config;
$serviceMock->profile = null;
$actualStartTime = \Carbon\Carbon::now();
$activity = $this->getMockBuilder(Activity::class)
->disableOriginalConstructor()
->onlyMethods(['update', 'hasProspect'])
->getMock();
$activity->method('update')->willReturn(true);
$activity->method('hasProspect')->willReturn(true);
$activity->type = Activity::TYPE_CONFERENCE;
$activity->provider = Activity::PROVIDER_TWILIO;
$activity->lead_id = 1;
$activity->user_id = 0;
$activity->id_string = 'test-activity-id';
$activity->user = $user;
$activity->actual_start_time = $actualStartTime;
$activity->uuid = 'c53d8320-f556-4cee-a2f8-5f232f454ca4';
app()->bind(PlaybookResolver::class, function () use ($user) {
$playbook = $this->createMock(Playbook::class);
$playbookResolver = $this->createMock(PlaybookResolver::class);
$playbookResolver->expects($this->once())
->method('resolvePlaybookByUser')
->with($user)
->willReturn($playbook);
return $playbookResolver;
});
$data = $serviceMock->fetchAndAssociateRelatedActivity($activity);
$this->assertInstanceOf(Activity::class, $data);
$this->assertEquals(Activity::TYPE_CONFERENCE, $data->getType());
$this->assertEquals($actualStartTime->getTimestamp(), $data->getActualStartTime()->getTimestamp());
}
public function testFetchAndAssociateRelatedActivitySkipsForTaskBasedPlaybook(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods(['fetchRelatedActivity', 'getPlaybook'])
->getMock();
$user = $this->createMock(User::class);
$playbook = $this->createMock(Playbook::class);
$playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);
$playbook->method('getId')->willReturn(123);
$serviceMock->expects($this->once())
->method('getPlaybook')
->with($user)
->willReturn($playbook);
$serviceMock->expects($this->never())
->method('fetchRelatedActivity');
$activity = $this->getMockBuilder(Activity::class)
->disableOriginalConstructor()
->onlyMethods(['hasProspect', 'getUuid'])
->getMock();
$activity->method('hasProspect')->willReturn(true);
$activity->method('getUuid')->willReturn('c53d8320-f556-4cee-a2f8-5f232f454ca4');
$activity->type = Activity::TYPE_CONFERENCE;
$activity->actual_start_time = \Carbon\Carbon::now();
$activity->user = $user;
$result = $serviceMock->fetchAndAssociateRelatedActivity($activity);
$this->assertNull($result);
}
public function testFetchAndAssociateRelatedActivityReturnsNullForNonConference(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$serviceMock = new Service(
client: $client,
payloadBuilder: $payloadBuilder,
eventDispatcher: $eventDispatcher,
countriesMap: $countriesMap,
prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)
);
$activity = $this->getMockBuilder(Activity::class)
->disableOriginalConstructor()
->getMock();
$activity->type = Activity::TYPE_SOFTPHONE;
$result = $serviceMock->fetchAndAssociateRelatedActivity($activity);
$this->assertNull($result);
}
public function testFetchAndAssociateRelatedActivityReturnsNullWhenNoStartTime(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$serviceMock = new Service(
client: $client,
payloadBuilder: $payloadBuilder,
eventDispatcher: $eventDispatcher,
countriesMap: $countriesMap,
prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)
);
$activity = $this->getMockBuilder(Activity::class)
->disableOriginalConstructor()
->getMock();
$activity->type = Activity::TYPE_CONFERENCE;
$activity->actual_start_time = null;
$activity->scheduled_start_time = null;
$result = $serviceMock->fetchAndAssociateRelatedActivity($activity);
$this->assertNull($result);
}
public function testFetchAndAssociateRelatedActivityReturnsNullWhenNoProspect(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods(['getPlaybook'])
->getMock();
$serviceMock->expects($this->never())
->method('getPlaybook');
$activity = $this->getMockBuilder(Activity::class)
->disableOriginalConstructor()
->onlyMethods(['hasProspect', 'getUuid'])
->getMock();
$activity->method('hasProspect')->willReturn(false);
$activity->method('getUuid')->willReturn('c53d8320-f556-4cee-a2f8-5f232f454ca4');
$activity->type = Activity::TYPE_CONFERENCE;
$activity->actual_start_time = \Carbon\Carbon::now();
$result = $serviceMock->fetchAndAssociateRelatedActivity($activity);
$this->assertNull($result);
}
public function testMatchExactlyByEmail(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods([])
->getMock();
$profile = new Profile();
$profile->setAttribute('id', bin2hex(random_bytes(8)));
$serviceMock->profile = $profile;
$team = $this->createMock(Team::class);
$serviceMock->team = $team;
$data = $serviceMock->matchExactlyByEmail(bin2hex(random_bytes(8)) . '[EMAIL]');
$this->assertEquals(null, $data);
}
public function testMatchDomainFromEmail(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$queryIterator = $this->createMock(QueryIterator::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$config = $this->createMock(Configuration::class);
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('search')
->willReturn($queryIterator);
return $handler;
});
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods(['convertCrmData'])
->getMock();
$profile = new Profile();
$profile->account_fields = 'Field1, Field2, Field3';
$serviceMock->profile = $profile;
$serviceMock->expects($this->once())
->method('convertCrmData')
->willReturn(['test']);
$this->app->bind(QueryBuilder::class, function () {
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->expects($this->once())
->method('buildMatchByDomainQuery')
->with('[EMAIL]')
->willReturn('FIND {[EMAIL]} IN ALL FIELDS RETURNING Account(Id)');
return $queryBuilder;
});
$team = $this->createMock(Team::class);
$serviceMock->team = $team;
$serviceMock->config = $config;
$data = $serviceMock->matchByDomain('[EMAIL]');
$this->assertEquals(['test'], $data);
}
public function testBuildTaskSearchFields(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$service = new Service(
client: $client,
payloadBuilder: $payloadBuilder,
eventDispatcher: $eventDispatcher,
countriesMap: $countriesMap,
prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)
);
$fields = $service->buildTaskSearchFields();
$expectedFields = ['Id', 'WhoId', 'WhatId', 'AccountId'];
$this->assertEquals($expectedFields, $fields);
}
public function testMapCrmObjects(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$service = new Service(
client: $client,
payloadBuilder: $payloadBuilder,
eventDispatcher: $eventDispatcher,
countriesMap: $countriesMap,
prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class),
);
$sampleTask = [
'WhoId' => '003sampleWhoId',
'AccountId' => 'sampleAccountId',
'WhatId' => 'sampleWhatId',
];
$activityData = $service->mapCrmObjects($sampleTask);
$expectedActivityData = [
'contact' => '003sampleWhoId',
'account' => 'sampleAccountId',
'opportunity' => 'sampleWhatId',
];
$this->assertEquals($expectedActivityData, $activityData);
}
public function testGetInstalledAppVersion(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$queryIterator = $this->createMock(QueryIterator::class);
$queryIterator->expects($this->any())
->method('current')->willReturn([
'SubscriberPackageVersion' => [
'MajorVersion' => '1',
'MinorVersion' => '0',
'PatchVersion' => '1',
'BuildNumber' => '0',
],
]);
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('metadata')
->willReturn($queryIterator);
return $handler;
});
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods(array_diff(get_class_methods(Service::class), ['getInstalledAppVersion']))
->getMock();
$version = $serviceMock->getInstalledAppVersion();
$this->assertEquals('1010', $version);
}
public function testSyncProfiles(): void
{
$userToSearch = null;
$team = $this->createMock(Team::class);
$config = $this->createMockedConfiguration();
$config->expects($this->once())
->method('getId')
->willReturn(1);
$salesforceUser = [
'Email' => '[EMAIL]',
'UserPreferencesLightningExperiencePreferred' => true,
'CallCenterId' => '123',
'Id' => '456',
'ProfileId' => '789',
];
app()->bind(QueryBuilder::class, function () use ($userToSearch) {
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->expects($this->once())
->method('buildGetUsersQuery')
->with($userToSearch)
->willReturn('SELECT * FROM Users');
return $queryBuilder;
});
$queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('query')
->with('SELECT * FROM Users')
->willReturn($queryIterator);
return $handler;
});
$user = $this->createMock(User::class);
$user->expects($this->once())
->method('getId')
->willReturn(123);
$this->mockTeamRepository($team, $salesforceUser, $user);
$profileRepository = $this->createMock(ProfileRepository::class);
$profileRepository->expects($this->once())
->method('updateOrCreateProfile')
->with(
$user,
[
'crm_configuration_id' => 1,
'crm_provider_id' => '456',
],
[
'user_id' => 123,
'edition' => Profile::EDITION_LIGHTNING,
'has_external_cti' => true,
'crm_profile_id' => '789',
]
)
->willReturn(new Profile());
$this->app->instance(ProfileRepository::class, $profileRepository);
$serviceMock = $this->getServiceMock();
$serviceMock->team = $team;
$serviceMock->config = $config;
$result = $serviceMock->syncProfiles($userToSearch);
$this->assertNull($result);
}
public function testSyncProfilesEmailIsNull(): void
{
$userToSearch = $this->createMock(User::class);
$salesforceUser = [
'Email' => null,
];
app()->bind(QueryBuilder::class, function () {
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->expects($this->once())
->method('buildGetUsersQuery')
->with(null)
->willReturn('SELECT * FROM Users');
return $queryBuilder;
});
$queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->exactly(2))
->method('query')
->willReturn($queryIterator);
return $handler;
});
$team = $this->createMock(Team::class);
$user = $this->createMock(User::class);
$this->mockTeamRepository($team, $salesforceUser, $user, false);
$config = $this->createMock(Configuration::class);
$serviceMock = $this->getServiceMock();
$serviceMock->team = $team;
$serviceMock->config = $config;
$profile = $serviceMock->syncProfiles(null);
$this->assertNull($profile);
}
public function testSyncProfilesUserToSearchMatchesCurrentUser(): void
{
$userToSearch = $this->createMock(User::class);
$userToSearch->expects($this->once())
->method('getId')
->willReturn(123);
$team = $this->createMock(Team::class);
$config = $this->createMock(Configuration::class);
$config->expects($this->once())
->method('getId')
->willReturn(1);
$salesforceUser = [
'Email' => '[EMAIL]',
'UserPreferencesLightningExperiencePreferred' => true,
'CallCenterId' => '123',
'Id' => '456',
'ProfileId' => '789',
];
app()->bind(QueryBuilder::class, function () use ($userToSearch) {
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->expects($this->once())
->method('buildGetUsersQuery')
->with($userToSearch)
->willReturn('SELECT * FROM Users');
return $queryBuilder;
});
$queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('query')
->with('SELECT * FROM Users')
->willReturn($queryIterator);
return $handler;
});
$user = $this->createMock(User::class);
$user->expects($this->exactly(2))
->method('getId')
->willReturn(123);
$this->mockTeamRepository($team, $salesforceUser, $user);
$profileRepository = $this->createMock(ProfileRepository::class);
$profileRepository->expects($this->once())
->method('updateOrCreateProfile')
->with(
$user,
[
'crm_configuration_id' => 1,
'crm_provider_id' => '456',
],
[
'user_id' => 123,
'edition' => Profile::EDITION_LIGHTNING,
'has_external_cti' => true,
'crm_profile_id' => '789',
]
)
->willReturn(new Profile());
$this->app->instance(ProfileRepository::class, $profileRepository);
$serviceMock = $this->getServiceMock();
$serviceMock->team = $team;
$serviceMock->config = $config;
$profile = $serviceMock->syncProfiles($userToSearch);
$this->assertInstanceOf(Profile::class, $profile);
}
public function testSyncProfilesWithCustomValidation(): void
{
$userToSearch = null;
$team = $this->createMock(Team::class);
$config = $this->createMockedConfiguration();
$config->expects($this->atLeastOnce()) // Changed from once() to atLeastOnce()
->method('getId')
->willReturn(1);
$salesforceUser = [
'Email' => '[EMAIL]',
'UserPreferencesLightningExperiencePreferred' => true,
'CallCenterId' => '123',
'Id' => '456',
'ProfileId' => '789',
'CustomField' => 'CustomValue',
];
$customRules = [
['field' => 'CustomField', 'value' => 'CustomValue'],
];
$this->mockQueryBuilderAndHandler($userToSearch, [$salesforceUser]);
$user = $this->createMock(User::class);
$user->expects($this->once())
->method('getId')
->willReturn(123);
$this->mockTeamRepository($team, $salesforceUser, $user, true, $customRules);
$this->mockProfileRepository($user);
$serviceMock = $this->getServiceMock();
$serviceMock->team = $team;
$serviceMock->config = $config;
$profile = $serviceMock->syncProfiles($userToSearch);
$this->assertNull($profile);
}
public function testSyncProfilesWithCustomValidationFailing(): void
{
$userToSearch = null;
$team = $this->createMock(Team::class);
$config = $this->createMockedConfiguration();
$salesforceUser = [
'Email' => '[EMAIL]',
'UserPreferencesLightningExperiencePreferred' => true,
'CallCenterId' => '123',
'Id' => '456',
'ProfileId' => '789',
'CustomField' => 'WrongValue',
];
$customRules = [
['field' => 'CustomField', 'value' => 'CustomValue'],
];
$this->mockQueryBuilderAndHandler($userToSearch, [$salesforceUser]);
$teamRepository = $this->getMockForAbstractClass(TeamRepository::class, [], '', false, true, true, ['findActiveTeamMemberByEmail', 'getTeamSetting']);
$teamSettings = $this->createMock(TeamSettings::class);
$teamSettings->method('getValueType')
->willReturn('array');
$teamSettings->method('getValue')
->willReturn(json_encode($customRules));
$teamRepository->expects($this->once())
->method('getTeamSetting')
->with($team, 'custom_profile_validation')
->willReturn($teamSettings);
app()->bind(TeamRepository::class, function () use ($teamRepository) {
return $teamRepository;
});
$profileRepository = $this->createMock(ProfileRepository::class);
$profileRepository->expects($this->never())
->method('updateOrCreateProfile');
$this->app->instance(ProfileRepository::class, $profileRepository);
$serviceMock = $this->getServiceMock();
$serviceMock->team = $team;
$serviceMock->config = $config;
$result = $serviceMock->syncProfiles($userToSearch);
$this->assertNull($result);
}
public function testGetContactRolesFromCrm(): void
{
$contactRoles = [
[
'Id' => '1',
'ContactId' => 'Contact1',
'OpportunityId' => 'Opportunity1',
'Opportunity' => ['OwnerId' => 'Owner1'],
'IsPrimary' => true,
'Role' => 'Decision Maker',
],
];
$expectedResponse = [
[
'id' => '1',
'contactId' => 'Contact1',
'opportunityId' => 'Opportunity1',
'ownerId' => 'Owner1',
'isPrimary' => true,
'role' => 'Decision Maker',
],
];
$this->bindQueryIterator($contactRoles);
$serviceMock = $this->getServiceMock();
$data = $serviceMock->getContactRolesFromCrm(now()->subDay());
$this->assertEquals($expectedResponse, $data);
}
public function testGetContactRolesFromCrmNoResult(): void
{
$this->bindQueryIterator([]);
$serviceMock = $this->getServiceMock();
$data = $serviceMock->getContactRolesFromCrm(now()->subDay());
$this->assertEquals([], $data);
}
public function testSyncContactRoles(): void
{
$contactRoles = [
[
'id' => '1',
'contactId' => 'Contact1',
'opportunityId' => 'Opportunity1',
'ownerId' => 'Owner1',
'isPrimary' => true,
'role' => 'Decision Maker',
],
];
app()->bind(ContactRoleRepository::class, function () {
$contactRoleRepository = $this->createMock(ContactRoleRepository::class);
$contactRoleRepository->expects($this->once())
->method('saveContactRoles');
return $contactRoleRepository;
});
$serviceMock = $this->getServiceMock([
'getContactRolesFromCrm',
'syncRemotelyDeletedContactRoles',
'syncContact',
'syncOpportunity',
]);
$config = $this->createMock(Configuration::class);
$hasMany = $this->createMock(HasManyExtended::class);
$hasMany->expects($this->exactly(2))
->method('where')
->willReturn($hasMany);
$hasMany->expects($this->exactly(2))
->method('first')
->willReturn(
$this->createMock(Contact::class),
$this->createMock(Opportunity::class)
);
$config->expects($this->once())
->method('contacts')
->willReturn($hasMany);
$config->expects($this->once())
->method('opportunities')
->willReturn($hasMany);
$serviceMock->config = $config;
$serviceMock->expects($this->once())
->method('getContactRolesFromCrm')
->willReturn($contactRoles);
$serviceMock->expects($this->once())
->method('syncRemotelyDeletedContactRoles');
$data = $serviceMock->syncContactRoles(now()->subDay());
$this->assertEquals(1, $data);
}
public function testSyncRemotelyDeletedContactRoles(): void
{
$contactRoles = [
[
'id' => '1',
'crm_provider_id' => '1',
],
];
app()->bind(QueryHandler::class, function () use ($contactRoles) {
$queryResults = new QueryResults($contactRoles, 1, true, null);
$handler = $this->createMock(QueryHandler::class);
$handler->method('queryDeleted')
->willReturn($queryResults);
return $handler;
});
app()->bind(ContactRoleRepository::class, function () {
$contactRoleRepository = $this->createMock(ContactRoleRepository::class);
$contactRoleRepository->expects($this->once())
->method('deleteContactRoles');
return $contactRoleRepository;
});
$serviceMock = $this->getServiceMock();
$serviceMock->team = $this->createMock(Team::class);
$data = $this->invokePrivateMethod('syncRemotelyDeletedContactRoles', $serviceMock, []);
$this->assertTrue($data);
}
private function bindQueryIterator(array $queryResult): void
{
/** @var Client $client */
$client = $this->createMock(Client::class);
$queryIterator = new QueryIterator(
$client,
new QueryResults($queryResult, 1, true, null)
);
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('query')
->willReturn($queryIterator);
return $handler;
});
}
public static function getOpportunitySortOrderDataProvider(): array
{
return [
'all open recently updated' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED, ['LastModifiedDate DESC', true]],
'all open recently created' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED, ['CreatedDate DESC', true]],
'all open oldest created' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED, ['CreatedDate ASC', true]],
'all recently updated' => [Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED, ['LastModifiedDate DESC', false]],
'default' => ['unknown', ['LastModifiedDate DESC', true]],
];
}
private function createMockedConfiguration(): Configuration
{
$config = $this->createMock(Configuration::class);
$profilesRelation = $this->getMockBuilder(\Illuminate\Database\Eloquent\Relations\HasMany::class)
->disableOriginalConstructor()
->onlyMethods(['get'])
->addMethods(['where', 'first'])
->getMock();
$profilesRelation->method('where')->willReturnSelf();
$profilesRelation->method('get')->willReturn(collect([]));
$profilesRelation->method('first')->willReturn(null);
$config->method('profiles')->willReturn($profilesRelation);
return $config;
}
private function getServiceMock(array $onlyMethods = []): MockObject&Service
{
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$this->createMock(Client::class),
$this->createMock(PayloadBuilder::class),
$this->createMock(Dispatcher::class),
$this->createMock(CountriesMap::class),
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods($onlyMethods)
->getMock();
$serviceMock->profile = $this->createMock(Profile::class);
return $serviceMock;
}
private function mockQueryBuilderAndHandler($userToSearch, $salesforceUsers): void
{
app()->bind(QueryBuilder::class, function () use ($userToSearch) {
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->expects($this->once())
->method('buildGetUsersQuery')
->with($userToSearch)
->willReturn('SELECT * FROM Users');
return $queryBuilder;
});
$queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults($salesforceUsers, count($salesforceUsers), true, null));
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('query')
->willReturn($queryIterator);
return $handler;
});
}
private function mockTeamRepository(
Team $team,
array $salesforceUser,
?User $user = null,
bool $userSearch = true,
array $customRules = []
): void {
$teamRepository = $this->getMockForAbstractClass(TeamRepository::class, [], '', false, true, true, ['findActiveTeamMemberByEmail', 'getTeamSetting']);
if ($userSearch) {
$teamRepository->expects($this->once())
->method('findActiveTeamMemberByEmail')
->with($team, $salesforceUser['Email'])
->willReturn($user);
}
$teamSettings = $this->createMock(TeamSettings::class);
$teamSettings->method('getValueType')
->willReturn('array');
$teamSettings->method('getValue')
->willReturn(json_encode($customRules));
$teamRepository->expects($this->once())
->method('getTeamSetting')
->with($team, 'custom_profile_validation')
->willReturn($teamSettings);
app()->bind(TeamRepository::class, function () use ($teamRepository) {
return $teamRepository;
});
}
private function mockProfileRepository(User $user): void
{
$profileRepository = $this->createMock(ProfileRepository::class);
$profileRepository->expects($this->once())
->method('updateOrCreateProfile')
->with(
$user,
[
'crm_configuration_id' => 1,
'crm_provider_id' => '456',
],
[
'user_id' => 123,
'edition' => Profile::EDITION_LIGHTNING,
'has_external_cti' => true,
'crm_profile_id' => '789',
]
)
->willReturn(new Profile());
$this->app->instance(ProfileRepository::class, $profileRepository);
}
public function testBuildEnhancedNoteDecodesHtmlEntities(): void
{
$service = $this->getServiceMock(['createRecord']);
$profile = new Profile();
$profile->setAttribute('crm_provider_id', 'owner-123');
$service->profile = $profile;
$service->expects($this->exactly(2))
->method('createRecord')
->willReturnOnConsecutiveCalls('note-id-123', 'link-id-456');
$bodyWithEntities = 'Welch's current challenges and Facebook's Club';
$result = $this->invokePrivateMethod('buildEnhancedNote', $service, [
'Test Title',
$bodyWithEntities,
'object-id-789',
]);
$this->assertEquals('note-id-123', $result);
}
public function testBuildEnhancedNoteSanitizesWithoutQuotes(): void
{
$service = $this->getServiceMock(['createRecord']);
$profile = new Profile();
$profile->setAttribute('crm_provider_id', 'owner-456');
$service->profile = $profile;
$service->expects($this->exactly(2))
->method('createRecord')
->willReturnCallback(function ($type, $data) {
if ($type === 'ContentNote') {
$decoded = base64_decode($data['Content']);
$this->assertStringContainsString("Welch's", $decoded);
$this->assertStringNotContainsString(''', $decoded);
$this->assertStringNotContainsString('&#039;', $decoded);
$this->assertStringContainsString('<script>', $decoded);
return 'note-id-456';
}
return 'link-id-789';
});
$bodyWithMixedContent = "Welch's and <script>alert('xss')</script>";
$result = $this->invokePrivateMethod('buildEnhancedNote', $service, [
'Test Title',
$bodyWithMixedContent,
'object-id-123',
]);
$this->assertEquals('note-id-456', $result);
}
public function testBuildEnhancedNoteConvertsLineBreaks(): void
{
$service = $this->getServiceMock(['createRecord']);
$profile = new Profile();
$profile->setAttribute('crm_provider_id', 'owner-789');
$service->profile = $profile;
$service->expects($this->exactly(2))
->method('createRecord')
->willReturnCallback(function ($type, $data) {
if ($type === 'ContentNote') {
$decoded = base64_decode($data['Content']);
$this->assertStringContainsString('<br>', $decoded);
$this->assertStringNotContainsString('<br />', $decoded);
return 'note-id-789';
}
return 'link-id-012';
});
$bodyWithLineBreaks = "Line 1\nLine 2\nLine 3";
$result = $this->invokePrivateMethod('buildEnhancedNote', $service, [
'Test Title',
$bodyWithLineBreaks,
'object-id-456',
]);
$this->assertEquals('note-id-789', $result);
}
public function testBuildEnhancedNoteHandlesComplexScenario(): void
{
$service = $this->getServiceMock(['createRecord']);
$profile = new Profile();
$profile->setAttribute('crm_provider_id', 'owner-complex');
$service->profile = $profile;
$service->expects($this->exactly(2))
->method('createRecord')
->willReturnCallback(function ($type, $data) {
if ($type === 'ContentNote') {
$decoded = base64_decode($data['Content']);
$this->assertStringContainsString("Welch's", $decoded);
$this->assertStringContainsString("Facebook's Club", $decoded);
$this->assertStringContainsString("Arctics'", $decoded);
$this->assertStringNotContainsString(''', $decoded);
$this->assertStringNotContainsString('&#039;', $decoded);
$this->assertStringContainsString('<br>', $decoded);
$this->assertStringContainsString('<', $decoded);
$this->assertStringContainsString('>', $decoded);
return 'note-complex';
}
return 'link-complex';
});
$complexBody = "Summary:\n---------\nThe call focused on understanding Welch's current challenges and exploring how Arctics' Revenue Growth Management solutions could support their strategic goals.\n\n• John SMith discussed his role as a category advisor for Google and Facebook's Club, emphasizing the importance of market research and advising on product assortment.\n• Madona introduced Arctics' Virtual Shoppers AI, which simulates consumer <behavior> to optimize pricing and promotional strategies.";
$result = $this->invokePrivateMethod('buildEnhancedNote', $service, [
'Jiminny Transcription Summary',
$complexBody,
'task-id-001',
]);
$this->assertEquals('note-complex', $result);
}
public function testSyncRemotelyDeletedObjectsWithErrorHandlingSuccess(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$prospectPhotoPathService,
])
->onlyMethods(['syncRemotelyDeletedObjects'])
->getMock();
// Mock team
$team = $this->createMock(Team::class);
$team->method('getUuid')->willReturn('team-uuid-123');
$serviceMock->team = $team;
// Expect syncRemotelyDeletedObjects to be called once and succeed
$serviceMock->expects($this->once())
->method('syncRemotelyDeletedObjects')
->with(\Jiminny\Enums\CrmObject::ACCOUNT);
// Call the protected method using reflection
$reflection = new \ReflectionClass($serviceMock);
$method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');
$method->setAccessible(true);
// Should not throw any exceptions
$method->invoke($serviceMock, \Jiminny\Enums\CrmObject::ACCOUNT);
$this->assertTrue(true); // Test completed successfully
}
public function testSyncRemotelyDeletedObjectsWithErrorHandlingFailure(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$prospectPhotoPathService,
])
->onlyMethods(['syncRemotelyDeletedObjects'])
->getMock();
// Mock team
$team = $this->createMock(Team::class);
$team->method('getUuid')->willReturn('team-uuid-456');
$serviceMock->team = $team;
// Mock logger to verify warning is logged
$logger = $this->createMock(\Psr\Log\LoggerInterface::class);
// Use reflection to set the protected logger property
$reflection = new \ReflectionClass($serviceMock);
$loggerProperty = $reflection->getProperty('logger');
$loggerProperty->setAccessible(true);
$loggerProperty->setValue($serviceMock, $logger);
$exception = new \Exception('Sync failed due to API error');
// Expect syncRemotelyDeletedObjects to throw an exception
$serviceMock->expects($this->once())
->method('syncRemotelyDeletedObjects')
->with(\Jiminny\Enums\CrmObject::CONTACT)
->willThrowException($exception);
// Expect warning to be logged with correct message and parameters
$logger->expects($this->once())
->method('warning')
->with(
'[Salesforce] Remotely deleted objects sync failed',
[
'objectType' => 'contact',
'teamId' => 'team-uuid-456',
'reason' => 'Sync failed due to API error',
]
);
// Call the protected method using reflection
$reflection = new \ReflectionClass($serviceMock);
$method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');
$method->setAccessible(true);
// Should not re-throw the exception, just log it
$method->invoke($serviceMock, \Jiminny\Enums\CrmObject::CONTACT);
$this->assertTrue(true); // Test completed successfully
}
public function testSyncRemotelyDeletedObjectsWithErrorHandlingWithLogParams(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$prospectPhotoPathService,
])
->onlyMethods(['syncRemotelyDeletedObjects'])
->getMock();
// Mock team
$team = $this->createMock(Team::class);
$team->method('getUuid')->willReturn('team-uuid-789');
$serviceMock->team = $team;
// Mock logger to verify warning is logged
$logger = $this->createMock(\Psr\Log\LoggerInterface::class);
// Use reflection to set the protected logger property
$loggerReflection = new \ReflectionClass($serviceMock);
$loggerProperty = $loggerReflection->getProperty('logger');
$loggerProperty->setAccessible(true);
$loggerProperty->setValue($serviceMock, $logger);
$exception = new \Exception('Network timeout');
// Expect syncRemotelyDeletedObjects to throw an exception
$serviceMock->expects($this->once())
->method('syncRemotelyDeletedObjects')
->with(\Jiminny\Enums\CrmObject::OPPORTUNITY)
->willThrowException($exception);
// Additional log parameters
$logParams = [
'syncType' => 'full',
'batchSize' => 100,
];
// Expect warning to be logged with merged parameters
$logger->expects($this->once())
->method('warning')
->with(
'[Salesforce] Remotely deleted objects sync failed',
[
'objectType' => 'opportunity',
'teamId' => 'team-uuid-789',
'reason' => 'Network timeout',
'syncType' => 'full',
'batchSize' => 100,
]
);
// Call the protected method using reflection
$reflection = new \ReflectionClass($serviceMock);
$method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');
$method->setAccessible(true);...
|
70635
|
NULL
|
NULL
|
NULL
|
|
70634
|
2536
|
10
|
2026-05-22T10:26:07.483937+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779445567483_m1.jpg...
|
PhpStorm
|
faVsco.js – ServiceTest.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20963-fix-import-on-de Project: faVsco.js, menu
JY-20963-fix-import-on-deleted-entity, menu
Start Listening for PHP Debug Connections
ServiceTest
Run 'ServiceTest'
Debug 'ServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Built-in Preview
Chrome
Firefox
Safari
Sync Changes
Hide This Notification
Code changed:
Hide
4
32
168
1
28
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Tests\Unit\Services\Crm\Salesforce;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Events\Dispatcher;
use Illuminate\Support\Facades\Cache;
use Jiminny\Component\Country\CountriesMap;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Enums\CrmObject;
use Jiminny\Exceptions\InvalidArgumentException;
use Jiminny\Exceptions\NoResultsException;
use Jiminny\Integrations\PlaybookResolver;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Playbook;
use Jiminny\Models\Team;
use Jiminny\Models\TeamSettings;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\ContactRoleRepository;
use Jiminny\Repositories\Crm\FieldDataRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\Salesforce\Client;
use Jiminny\Services\Crm\Salesforce\PayloadBuilder;
use Jiminny\Services\Crm\Salesforce\QueryBuilder;
use Jiminny\Services\Crm\Salesforce\QueryHandler;
use Jiminny\Services\Crm\Salesforce\QueryIterator;
use Jiminny\Services\Crm\Salesforce\QueryResults;
use Jiminny\Services\Crm\Salesforce\Service;
use PHPUnit\Framework\MockObject\MockObject;
use Tests\TestCase;
use Tests\Unit\Traits\TestPrivateMethod;
class ServiceTest extends TestCase
{
use TestPrivateMethod;
public function testFetchAndAssociateRelatedActivity(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$payloadBuilder->method('addCustomLogicFieldsPayload')
->willReturnCallback(function ($activity, $payload) {
return $payload;
});
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods(['fetchRelatedActivity', 'getPlaybook', 'getPlaybookCategory', 'updateRecord'])
->getMock();
$serviceMock->expects($this->once())
->method('fetchRelatedActivity')
->willReturn([
'Id' => 'testId',
'Type' => null,
'OwnerId' => 'testerUser',
'Description' => 'Test description',
]);
$user = $this->createMock(User::class);
$team = $this->createMock(Team::class);
$user->method('getAttribute')->with('team')->willReturn($team);
$playbook = $this->createMock(Playbook::class);
$playbook->method('getActivityField')->willReturn(null);
$playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_EVENT);
$serviceMock->expects($this->once())
->method('getPlaybook')
->with($user)
->willReturn($playbook);
$serviceMock->expects($this->never())
->method('getPlaybookCategory');
$serviceMock->expects($this->never())
->method('updateRecord');
$fieldDataRepository = $this->createMock(FieldDataRepository::class);
$fieldDataRepository->method('getActivityFieldData')->willReturn(collect([]));
app()->instance(FieldDataRepository::class, $fieldDataRepository);
$config = $this->createMock(Configuration::class);
$profilesRelation = $this->getMockBuilder(\Illuminate\Database\Eloquent\Relations\HasMany::class)
->disableOriginalConstructor()
->onlyMethods(['get'])
->addMethods(['where'])
->getMock();
$profilesRelation->method('where')->willReturnSelf();
$profilesRelation->method('get')->willReturn(collect([]));
$config->method('profiles')->willReturn($profilesRelation);
$serviceMock->config = $config;
$serviceMock->profile = null;
$actualStartTime = \Carbon\Carbon::now();
$activity = $this->getMockBuilder(Activity::class)
->disableOriginalConstructor()
->onlyMethods(['update', 'hasProspect'])
->getMock();
$activity->method('update')->willReturn(true);
$activity->method('hasProspect')->willReturn(true);
$activity->type = Activity::TYPE_CONFERENCE;
$activity->provider = Activity::PROVIDER_TWILIO;
$activity->lead_id = 1;
$activity->user_id = 0;
$activity->id_string = 'test-activity-id';
$activity->user = $user;
$activity->actual_start_time = $actualStartTime;
$activity->uuid = 'c53d8320-f556-4cee-a2f8-5f232f454ca4';
app()->bind(PlaybookResolver::class, function () use ($user) {
$playbook = $this->createMock(Playbook::class);
$playbookResolver = $this->createMock(PlaybookResolver::class);
$playbookResolver->expects($this->once())
->method('resolvePlaybookByUser')
->with($user)
->willReturn($playbook);
return $playbookResolver;
});
$data = $serviceMock->fetchAndAssociateRelatedActivity($activity);
$this->assertInstanceOf(Activity::class, $data);
$this->assertEquals(Activity::TYPE_CONFERENCE, $data->getType());
$this->assertEquals($actualStartTime->getTimestamp(), $data->getActualStartTime()->getTimestamp());
}
public function testFetchAndAssociateRelatedActivitySkipsForTaskBasedPlaybook(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods(['fetchRelatedActivity', 'getPlaybook'])
->getMock();
$user = $this->createMock(User::class);
$playbook = $this->createMock(Playbook::class);
$playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);
$playbook->method('getId')->willReturn(123);
$serviceMock->expects($this->once())
->method('getPlaybook')
->with($user)
->willReturn($playbook);
$serviceMock->expects($this->never())
->method('fetchRelatedActivity');
$activity = $this->getMockBuilder(Activity::class)
->disableOriginalConstructor()
->onlyMethods(['hasProspect', 'getUuid'])
->getMock();
$activity->method('hasProspect')->willReturn(true);
$activity->method('getUuid')->willReturn('c53d8320-f556-4cee-a2f8-5f232f454ca4');
$activity->type = Activity::TYPE_CONFERENCE;
$activity->actual_start_time = \Carbon\Carbon::now();
$activity->user = $user;
$result = $serviceMock->fetchAndAssociateRelatedActivity($activity);
$this->assertNull($result);
}
public function testFetchAndAssociateRelatedActivityReturnsNullForNonConference(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$serviceMock = new Service(
client: $client,
payloadBuilder: $payloadBuilder,
eventDispatcher: $eventDispatcher,
countriesMap: $countriesMap,
prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)
);
$activity = $this->getMockBuilder(Activity::class)
->disableOriginalConstructor()
->getMock();
$activity->type = Activity::TYPE_SOFTPHONE;
$result = $serviceMock->fetchAndAssociateRelatedActivity($activity);
$this->assertNull($result);
}
public function testFetchAndAssociateRelatedActivityReturnsNullWhenNoStartTime(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$serviceMock = new Service(
client: $client,
payloadBuilder: $payloadBuilder,
eventDispatcher: $eventDispatcher,
countriesMap: $countriesMap,
prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)
);
$activity = $this->getMockBuilder(Activity::class)
->disableOriginalConstructor()
->getMock();
$activity->type = Activity::TYPE_CONFERENCE;
$activity->actual_start_time = null;
$activity->scheduled_start_time = null;
$result = $serviceMock->fetchAndAssociateRelatedActivity($activity);
$this->assertNull($result);
}
public function testFetchAndAssociateRelatedActivityReturnsNullWhenNoProspect(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods(['getPlaybook'])
->getMock();
$serviceMock->expects($this->never())
->method('getPlaybook');
$activity = $this->getMockBuilder(Activity::class)
->disableOriginalConstructor()
->onlyMethods(['hasProspect', 'getUuid'])
->getMock();
$activity->method('hasProspect')->willReturn(false);
$activity->method('getUuid')->willReturn('c53d8320-f556-4cee-a2f8-5f232f454ca4');
$activity->type = Activity::TYPE_CONFERENCE;
$activity->actual_start_time = \Carbon\Carbon::now();
$result = $serviceMock->fetchAndAssociateRelatedActivity($activity);
$this->assertNull($result);
}
public function testMatchExactlyByEmail(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods([])
->getMock();
$profile = new Profile();
$profile->setAttribute('id', bin2hex(random_bytes(8)));
$serviceMock->profile = $profile;
$team = $this->createMock(Team::class);
$serviceMock->team = $team;
$data = $serviceMock->matchExactlyByEmail(bin2hex(random_bytes(8)) . '[EMAIL]');
$this->assertEquals(null, $data);
}
public function testMatchDomainFromEmail(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$queryIterator = $this->createMock(QueryIterator::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$config = $this->createMock(Configuration::class);
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('search')
->willReturn($queryIterator);
return $handler;
});
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods(['convertCrmData'])
->getMock();
$profile = new Profile();
$profile->account_fields = 'Field1, Field2, Field3';
$serviceMock->profile = $profile;
$serviceMock->expects($this->once())
->method('convertCrmData')
->willReturn(['test']);
$this->app->bind(QueryBuilder::class, function () {
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->expects($this->once())
->method('buildMatchByDomainQuery')
->with('[EMAIL]')
->willReturn('FIND {[EMAIL]} IN ALL FIELDS RETURNING Account(Id)');
return $queryBuilder;
});
$team = $this->createMock(Team::class);
$serviceMock->team = $team;
$serviceMock->config = $config;
$data = $serviceMock->matchByDomain('[EMAIL]');
$this->assertEquals(['test'], $data);
}
public function testBuildTaskSearchFields(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$service = new Service(
client: $client,
payloadBuilder: $payloadBuilder,
eventDispatcher: $eventDispatcher,
countriesMap: $countriesMap,
prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)
);
$fields = $service->buildTaskSearchFields();
$expectedFields = ['Id', 'WhoId', 'WhatId', 'AccountId'];
$this->assertEquals($expectedFields, $fields);
}
public function testMapCrmObjects(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$service = new Service(
client: $client,
payloadBuilder: $payloadBuilder,
eventDispatcher: $eventDispatcher,
countriesMap: $countriesMap,
prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class),
);
$sampleTask = [
'WhoId' => '003sampleWhoId',
'AccountId' => 'sampleAccountId',
'WhatId' => 'sampleWhatId',
];
$activityData = $service->mapCrmObjects($sampleTask);
$expectedActivityData = [
'contact' => '003sampleWhoId',
'account' => 'sampleAccountId',
'opportunity' => 'sampleWhatId',
];
$this->assertEquals($expectedActivityData, $activityData);
}
public function testGetInstalledAppVersion(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$queryIterator = $this->createMock(QueryIterator::class);
$queryIterator->expects($this->any())
->method('current')->willReturn([
'SubscriberPackageVersion' => [
'MajorVersion' => '1',
'MinorVersion' => '0',
'PatchVersion' => '1',
'BuildNumber' => '0',
],
]);
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('metadata')
->willReturn($queryIterator);
return $handler;
});
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods(array_diff(get_class_methods(Service::class), ['getInstalledAppVersion']))
->getMock();
$version = $serviceMock->getInstalledAppVersion();
$this->assertEquals('1010', $version);
}
public function testSyncProfiles(): void
{
$userToSearch = null;
$team = $this->createMock(Team::class);
$config = $this->createMockedConfiguration();
$config->expects($this->once())
->method('getId')
->willReturn(1);
$salesforceUser = [
'Email' => '[EMAIL]',
'UserPreferencesLightningExperiencePreferred' => true,
'CallCenterId' => '123',
'Id' => '456',
'ProfileId' => '789',
];
app()->bind(QueryBuilder::class, function () use ($userToSearch) {
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->expects($this->once())
->method('buildGetUsersQuery')
->with($userToSearch)
->willReturn('SELECT * FROM Users');
return $queryBuilder;
});
$queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('query')
->with('SELECT * FROM Users')
->willReturn($queryIterator);
return $handler;
});
$user = $this->createMock(User::class);
$user->expects($this->once())
->method('getId')
->willReturn(123);
$this->mockTeamRepository($team, $salesforceUser, $user);
$profileRepository = $this->createMock(ProfileRepository::class);
$profileRepository->expects($this->once())
->method('updateOrCreateProfile')
->with(
$user,
[
'crm_configuration_id' => 1,
'crm_provider_id' => '456',
],
[
'user_id' => 123,
'edition' => Profile::EDITION_LIGHTNING,
'has_external_cti' => true,
'crm_profile_id' => '789',
]
)
->willReturn(new Profile());
$this->app->instance(ProfileRepository::class, $profileRepository);
$serviceMock = $this->getServiceMock();
$serviceMock->team = $team;
$serviceMock->config = $config;
$result = $serviceMock->syncProfiles($userToSearch);
$this->assertNull($result);
}
public function testSyncProfilesEmailIsNull(): void
{
$userToSearch = $this->createMock(User::class);
$salesforceUser = [
'Email' => null,
];
app()->bind(QueryBuilder::class, function () {
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->expects($this->once())
->method('buildGetUsersQuery')
->with(null)
->willReturn('SELECT * FROM Users');
return $queryBuilder;
});
$queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->exactly(2))
->method('query')
->willReturn($queryIterator);
return $handler;
});
$team = $this->createMock(Team::class);
$user = $this->createMock(User::class);
$this->mockTeamRepository($team, $salesforceUser, $user, false);
$config = $this->createMock(Configuration::class);
$serviceMock = $this->getServiceMock();
$serviceMock->team = $team;
$serviceMock->config = $config;
$profile = $serviceMock->syncProfiles(null);
$this->assertNull($profile);
}
public function testSyncProfilesUserToSearchMatchesCurrentUser(): void
{
$userToSearch = $this->createMock(User::class);
$userToSearch->expects($this->once())
->method('getId')
->willReturn(123);
$team = $this->createMock(Team::class);
$config = $this->createMock(Configuration::class);
$config->expects($this->once())
->method('getId')
->willReturn(1);
$salesforceUser = [
'Email' => '[EMAIL]',
'UserPreferencesLightningExperiencePreferred' => true,
'CallCenterId' => '123',
'Id' => '456',
'ProfileId' => '789',
];
app()->bind(QueryBuilder::class, function () use ($userToSearch) {
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->expects($this->once())
->method('buildGetUsersQuery')
->with($userToSearch)
->willReturn('SELECT * FROM Users');
return $queryBuilder;
});
$queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('query')
->with('SELECT * FROM Users')
->willReturn($queryIterator);
return $handler;
});
$user = $this->createMock(User::class);
$user->expects($this->exactly(2))
->method('getId')
->willReturn(123);
$this->mockTeamRepository($team, $salesforceUser, $user);
$profileRepository = $this->createMock(ProfileRepository::class);
$profileRepository->expects($this->once())
->method('updateOrCreateProfile')
->with(
$user,
[
'crm_configuration_id' => 1,
'crm_provider_id' => '456',
],
[
'user_id' => 123,
'edition' => Profile::EDITION_LIGHTNING,
'has_external_cti' => true,
'crm_profile_id' => '789',
]
)
->willReturn(new Profile());
$this->app->instance(ProfileRepository::class, $profileRepository);
$serviceMock = $this->getServiceMock();
$serviceMock->team = $team;
$serviceMock->config = $config;
$profile = $serviceMock->syncProfiles($userToSearch);
$this->assertInstanceOf(Profile::class, $profile);
}
public function testSyncProfilesWithCustomValidation(): void
{
$userToSearch = null;
$team = $this->createMock(Team::class);
$config = $this->createMockedConfiguration();
$config->expects($this->atLeastOnce()) // Changed from once() to atLeastOnce()
->method('getId')
->willReturn(1);
$salesforceUser = [
'Email' => '[EMAIL]',
'UserPreferencesLightningExperiencePreferred' => true,
'CallCenterId' => '123',
'Id' => '456',
'ProfileId' => '789',
'CustomField' => 'CustomValue',
];
$customRules = [
['field' => 'CustomField', 'value' => 'CustomValue'],
];
$this->mockQueryBuilderAndHandler($userToSearch, [$salesforceUser]);
$user = $this->createMock(User::class);
$user->expects($this->once())
->method('getId')
->willReturn(123);
$this->mockTeamRepository($team, $salesforceUser, $user, true, $customRules);
$this->mockProfileRepository($user);
$serviceMock = $this->getServiceMock();
$serviceMock->team = $team;
$serviceMock->config = $config;
$profile = $serviceMock->syncProfiles($userToSearch);
$this->assertNull($profile);
}
public function testSyncProfilesWithCustomValidationFailing(): void
{
$userToSearch = null;
$team = $this->createMock(Team::class);
$config = $this->createMockedConfiguration();
$salesforceUser = [
'Email' => '[EMAIL]',
'UserPreferencesLightningExperiencePreferred' => true,
'CallCenterId' => '123',
'Id' => '456',
'ProfileId' => '789',
'CustomField' => 'WrongValue',
];
$customRules = [
['field' => 'CustomField', 'value' => 'CustomValue'],
];
$this->mockQueryBuilderAndHandler($userToSearch, [$salesforceUser]);
$teamRepository = $this->getMockForAbstractClass(TeamRepository::class, [], '', false, true, true, ['findActiveTeamMemberByEmail', 'getTeamSetting']);
$teamSettings = $this->createMock(TeamSettings::class);
$teamSettings->method('getValueType')
->willReturn('array');
$teamSettings->method('getValue')
->willReturn(json_encode($customRules));
$teamRepository->expects($this->once())
->method('getTeamSetting')
->with($team, 'custom_profile_validation')
->willReturn($teamSettings);
app()->bind(TeamRepository::class, function () use ($teamRepository) {
return $teamRepository;
});
$profileRepository = $this->createMock(ProfileRepository::class);
$profileRepository->expects($this->never())
->method('updateOrCreateProfile');
$this->app->instance(ProfileRepository::class, $profileRepository);
$serviceMock = $this->getServiceMock();
$serviceMock->team = $team;
$serviceMock->config = $config;
$result = $serviceMock->syncProfiles($userToSearch);
$this->assertNull($result);
}
public function testGetContactRolesFromCrm(): void
{
$contactRoles = [
[
'Id' => '1',
'ContactId' => 'Contact1',
'OpportunityId' => 'Opportunity1',
'Opportunity' => ['OwnerId' => 'Owner1'],
'IsPrimary' => true,
'Role' => 'Decision Maker',
],
];
$expectedResponse = [
[
'id' => '1',
'contactId' => 'Contact1',
'opportunityId' => 'Opportunity1',
'ownerId' => 'Owner1',
'isPrimary' => true,
'role' => 'Decision Maker',
],
];
$this->bindQueryIterator($contactRoles);
$serviceMock = $this->getServiceMock();
$data = $serviceMock->getContactRolesFromCrm(now()->subDay());
$this->assertEquals($expectedResponse, $data);
}
public function testGetContactRolesFromCrmNoResult(): void
{
$this->bindQueryIterator([]);
$serviceMock = $this->getServiceMock();
$data = $serviceMock->getContactRolesFromCrm(now()->subDay());
$this->assertEquals([], $data);
}
public function testSyncContactRoles(): void
{
$contactRoles = [
[
'id' => '1',
'contactId' => 'Contact1',
'opportunityId' => 'Opportunity1',
'ownerId' => 'Owner1',
'isPrimary' => true,
'role' => 'Decision Maker',
],
];
app()->bind(ContactRoleRepository::class, function () {
$contactRoleRepository = $this->createMock(ContactRoleRepository::class);
$contactRoleRepository->expects($this->once())
->method('saveContactRoles');
return $contactRoleRepository;
});
$serviceMock = $this->getServiceMock([
'getContactRolesFromCrm',
'syncRemotelyDeletedContactRoles',
'syncContact',
'syncOpportunity',
]);
$config = $this->createMock(Configuration::class);
$hasMany = $this->createMock(HasManyExtended::class);
$hasMany->expects($this->exactly(2))
->method('where')
->willReturn($hasMany);
$hasMany->expects($this->exactly(2))
->method('first')
->willReturn(
$this->createMock(Contact::class),
$this->createMock(Opportunity::class)
);
$config->expects($this->once())
->method('contacts')
->willReturn($hasMany);
$config->expects($this->once())
->method('opportunities')
->willReturn($hasMany);
$serviceMock->config = $config;
$serviceMock->expects($this->once())
->method('getContactRolesFromCrm')
->willReturn($contactRoles);
$serviceMock->expects($this->once())
->method('syncRemotelyDeletedContactRoles');
$data = $serviceMock->syncContactRoles(now()->subDay());
$this->assertEquals(1, $data);
}
public function testSyncRemotelyDeletedContactRoles(): void
{
$contactRoles = [
[
'id' => '1',
'crm_provider_id' => '1',
],
];
app()->bind(QueryHandler::class, function () use ($contactRoles) {
$queryResults = new QueryResults($contactRoles, 1, true, null);
$handler = $this->createMock(QueryHandler::class);
$handler->method('queryDeleted')
->willReturn($queryResults);
return $handler;
});
app()->bind(ContactRoleRepository::class, function () {
$contactRoleRepository = $this->createMock(ContactRoleRepository::class);
$contactRoleRepository->expects($this->once())
->method('deleteContactRoles');
return $contactRoleRepository;
});
$serviceMock = $this->getServiceMock();
$serviceMock->team = $this->createMock(Team::class);
$data = $this->invokePrivateMethod('syncRemotelyDeletedContactRoles', $serviceMock, []);
$this->assertTrue($data);
}
private function bindQueryIterator(array $queryResult): void
{
/** @var Client $client */
$client = $this->createMock(Client::class);
$queryIterator = new QueryIterator(
$client,
new QueryResults($queryResult, 1, true, null)
);
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('query')
->willReturn($queryIterator);
return $handler;
});
}
public static function getOpportunitySortOrderDataProvider(): array
{
return [
'all open recently updated' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED, ['LastModifiedDate DESC', true]],
'all open recently created' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED, ['CreatedDate DESC', true]],
'all open oldest created' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED, ['CreatedDate ASC', true]],
'all recently updated' => [Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED, ['LastModifiedDate DESC', false]],
'default' => ['unknown', ['LastModifiedDate DESC', true]],
];
}
private function createMockedConfiguration(): Configuration
{
$config = $this->createMock(Configuration::class);
$profilesRelation = $this->getMockBuilder(\Illuminate\Database\Eloquent\Relations\HasMany::class)
->disableOriginalConstructor()
->onlyMethods(['get'])
->addMethods(['where', 'first'])
->getMock();
$profilesRelation->method('where')->willReturnSelf();
$profilesRelation->method('get')->willReturn(collect([]));
$profilesRelation->method('first')->willReturn(null);
$config->method('profiles')->willReturn($profilesRelation);
return $config;
}
private function getServiceMock(array $onlyMethods = []): MockObject&Service
{
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$this->createMock(Client::class),
$this->createMock(PayloadBuilder::class),
$this->createMock(Dispatcher::class),
$this->createMock(CountriesMap::class),
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods($onlyMethods)
->getMock();
$serviceMock->profile = $this->createMock(Profile::class);
return $serviceMock;
}
private function mockQueryBuilderAndHandler($userToSearch, $salesforceUsers): void
{
app()->bind(QueryBuilder::class, function () use ($userToSearch) {
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->expects($this->once())
->method('buildGetUsersQuery')
->with($userToSearch)
->willReturn('SELECT * FROM Users');
return $queryBuilder;
});
$queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults($salesforceUsers, count($salesforceUsers), true, null));
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('query')
->willReturn($queryIterator);
return $handler;
});
}
private function mockTeamRepository(
Team $team,
array $salesforceUser,
?User $user = null,
bool $userSearch = true,
array $customRules = []
): void {
$teamRepository = $this->getMockForAbstractClass(TeamRepository::class, [], '', false, true, true, ['findActiveTeamMemberByEmail', 'getTeamSetting']);
if ($userSearch) {
$teamRepository->expects($this->once())
->method('findActiveTeamMemberByEmail')
->with($team, $salesforceUser['Email'])
->willReturn($user);
}
$teamSettings = $this->createMock(TeamSettings::class);
$teamSettings->method('getValueType')
->willReturn('array');
$teamSettings->method('getValue')
->willReturn(json_encode($customRules));
$teamRepository->expects($this->once())
->method('getTeamSetting')
->with($team, 'custom_profile_validation')
->willReturn($teamSettings);
app()->bind(TeamRepository::class, function () use ($teamRepository) {
return $teamRepository;
});
}
private function mockProfileRepository(User $user): void
{
$profileRepository = $this->createMock(ProfileRepository::class);
$profileRepository->expects($this->once())
->method('updateOrCreateProfile')
->with(
$user,
[
'crm_configuration_id' => 1,
'crm_provider_id' => '456',
],
[
'user_id' => 123,
'edition' => Profile::EDITION_LIGHTNING,
'has_external_cti' => true,
'crm_profile_id' => '789',
]
)
->willReturn(new Profile());
$this->app->instance(ProfileRepository::class, $profileRepository);
}
public function testBuildEnhancedNoteDecodesHtmlEntities(): void
{
$service = $this->getServiceMock(['createRecord']);
$profile = new Profile();
$profile->setAttribute('crm_provider_id', 'owner-123');
$service->profile = $profile;
$service->expects($this->exactly(2))
->method('createRecord')
->willReturnOnConsecutiveCalls('note-id-123', 'link-id-456');
$bodyWithEntities = 'Welch's current challenges and Facebook's Club';
$result = $this->invokePrivateMethod('buildEnhancedNote', $service, [
'Test Title',
$bodyWithEntities,
'object-id-789',
]);
$this->assertEquals('note-id-123', $result);
}
public function testBuildEnhancedNoteSanitizesWithoutQuotes(): void
{
$service = $this->getServiceMock(['createRecord']);
$profile = new Profile();
$profile->setAttribute('crm_provider_id', 'owner-456');
$service->profile = $profile;
$service->expects($this->exactly(2))
->method('createRecord')
->willReturnCallback(function ($type, $data) {
if ($type === 'ContentNote') {
$decoded = base64_decode($data['Content']);
$this->assertStringContainsString("Welch's", $decoded);
$this->assertStringNotContainsString(''', $decoded);
$this->assertStringNotContainsString('&#039;', $decoded);
$this->assertStringContainsString('<script>', $decoded);
return 'note-id-456';
}
return 'link-id-789';
});
$bodyWithMixedContent = "Welch's and <script>alert('xss')</script>";
$result = $this->invokePrivateMethod('buildEnhancedNote', $service, [
'Test Title',
$bodyWithMixedContent,
'object-id-123',
]);
$this->assertEquals('note-id-456', $result);
}
public function testBuildEnhancedNoteConvertsLineBreaks(): void
{
$service = $this->getServiceMock(['createRecord']);
$profile = new Profile();
$profile->setAttribute('crm_provider_id', 'owner-789');
$service->profile = $profile;
$service->expects($this->exactly(2))
->method('createRecord')
->willReturnCallback(function ($type, $data) {
if ($type === 'ContentNote') {
$decoded = base64_decode($data['Content']);
$this->assertStringContainsString('<br>', $decoded);
$this->assertStringNotContainsString('<br />', $decoded);
return 'note-id-789';
}
return 'link-id-012';
});
$bodyWithLineBreaks = "Line 1\nLine 2\nLine 3";
$result = $this->invokePrivateMethod('buildEnhancedNote', $service, [
'Test Title',
$bodyWithLineBreaks,
'object-id-456',
]);
$this->assertEquals('note-id-789', $result);
}
public function testBuildEnhancedNoteHandlesComplexScenario(): void
{
$service = $this->getServiceMock(['createRecord']);
$profile = new Profile();
$profile->setAttribute('crm_provider_id', 'owner-complex');
$service->profile = $profile;
$service->expects($this->exactly(2))
->method('createRecord')
->willReturnCallback(function ($type, $data) {
if ($type === 'ContentNote') {
$decoded = base64_decode($data['Content']);
$this->assertStringContainsString("Welch's", $decoded);
$this->assertStringContainsString("Facebook's Club", $decoded);
$this->assertStringContainsString("Arctics'", $decoded);
$this->assertStringNotContainsString(''', $decoded);
$this->assertStringNotContainsString('&#039;', $decoded);
$this->assertStringContainsString('<br>', $decoded);
$this->assertStringContainsString('<', $decoded);
$this->assertStringContainsString('>', $decoded);
return 'note-complex';
}
return 'link-complex';
});
$complexBody = "Summary:\n---------\nThe call focused on understanding Welch's current challenges and exploring how Arctics' Revenue Growth Management solutions could support their strategic goals.\n\n• John SMith discussed his role as a category advisor for Google and Facebook's Club, emphasizing the importance of market research and advising on product assortment.\n• Madona introduced Arctics' Virtual Shoppers AI, which simulates consumer <behavior> to optimize pricing and promotional strategies.";
$result = $this->invokePrivateMethod('buildEnhancedNote', $service, [
'Jiminny Transcription Summary',
$complexBody,
'task-id-001',
]);
$this->assertEquals('note-complex', $result);
}
public function testSyncRemotelyDeletedObjectsWithErrorHandlingSuccess(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$prospectPhotoPathService,
])
->onlyMethods(['syncRemotelyDeletedObjects'])
->getMock();
// Mock team
$team = $this->createMock(Team::class);
$team->method('getUuid')->willReturn('team-uuid-123');
$serviceMock->team = $team;
// Expect syncRemotelyDeletedObjects to be called once and succeed
$serviceMock->expects($this->once())
->method('syncRemotelyDeletedObjects')
->with(\Jiminny\Enums\CrmObject::ACCOUNT);
// Call the protected method using reflection
$reflection = new \ReflectionClass($serviceMock);
$method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');
$method->setAccessible(true);
// Should not throw any exceptions
$method->invoke($serviceMock, \Jiminny\Enums\CrmObject::ACCOUNT);
$this->assertTrue(true); // Test completed successfully
}
public function testSyncRemotelyDeletedObjectsWithErrorHandlingFailure(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$prospectPhotoPathService,
])
->onlyMethods(['syncRemotelyDeletedObjects'])
->getMock();
// Mock team
$team = $this->createMock(Team::class);
$team->method('getUuid')->willReturn('team-uuid-456');
$serviceMock->team = $team;
// Mock logger to verify warning is logged
$logger = $this->createMock(\Psr\Log\LoggerInterface::class);
// Use reflection to set the protected logger property
$reflection = new \ReflectionClass($serviceMock);
$loggerProperty = $reflection->getProperty('logger');
$loggerProperty->setAccessible(true);
$loggerProperty->setValue($serviceMock, $logger);
$exception = new \Exception('Sync failed due to API error');
// Expect syncRemotelyDeletedObjects to throw an exception
$serviceMock->expects($this->once())
->method('syncRemotelyDeletedObjects')
->with(\Jiminny\Enums\CrmObject::CONTACT)
->willThrowException($exception);
// Expect warning to be logged with correct message and parameters
$logger->expects($this->once())
->method('warning')
->with(
'[Salesforce] Remotely deleted objects sync failed',
[
'objectType' => 'contact',
'teamId' => 'team-uuid-456',
'reason' => 'Sync failed due to API error',
]
);
// Call the protected method using reflection
$reflection = new \ReflectionClass($serviceMock);
$method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');
$method->setAccessible(true);
// Should not re-throw the exception, just log it
$method->invoke($serviceMock, \Jiminny\Enums\CrmObject::CONTACT);
$this->assertTrue(true); // Test completed successfully
}
public function testSyncRemotelyDeletedObjectsWithErrorHandlingWithLogParams(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$prospectPhotoPathService,
])
->onlyMethods(['syncRemotelyDeletedObjects'])
->getMock();
// Mock team
$team = $this->createMock(Team::class);
$team->method('getUuid')->willReturn('team-uuid-789');
$serviceMock->team = $team;
// Mock logger to verify warning is logged
$logger = $this->createMock(\Psr\Log\LoggerInterface::class);
// Use reflection to set the protected logger property
$loggerReflection = new \ReflectionClass($serviceMock);
$loggerProperty = $loggerReflection->getProperty('logger');
$loggerProperty->setAccessible(true);
$loggerProperty->setValue($serviceMock, $logger);
$exception = new \Exception('Network timeout');
// Expect syncRemotelyDeletedObjects to throw an exception
$serviceMock->expects($this->once())
->method('syncRemotelyDeletedObjects')
->with(\Jiminny\Enums\CrmObject::OPPORTUNITY)
->willThrowException($exception);
// Additional log parameters
$logParams = [
'syncType' => 'full',
'batchSize' => 100,
];
// Expect warning to be logged with merged parameters
$logger->expects($this->once())
->method('warning')
->with(
'[Salesforce] Remotely deleted objects sync failed',
[
'objectType' => 'opportunity',
'teamId' => 'team-uuid-789',
'reason' => 'Network timeout',
'syncType' => 'full',
'batchSize' => 100,
]
);
// Call the protected method using reflection
$reflection = new \ReflectionClass($serviceMock);
$method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');
$method->setAccessible(true);...
|
[{"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-20963-fix-import-on-deleted-entity, menu","depth":5,"on_screen":true,"help_text":"Git Branch: JY-20963-fix-import-on-deleted-entity","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"ServiceTest","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'ServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'ServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Built-in Preview","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Chrome","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Firefox","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Safari","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"4","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"32","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"168","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"28","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Services\\Crm\\Salesforce;\n\nuse Illuminate\\Database\\Eloquent\\Relations\\HasMany;\nuse Illuminate\\Events\\Dispatcher;\nuse Illuminate\\Support\\Facades\\Cache;\nuse Jiminny\\Component\\Country\\CountriesMap;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Enums\\CrmObject;\nuse Jiminny\\Exceptions\\InvalidArgumentException;\nuse Jiminny\\Exceptions\\NoResultsException;\nuse Jiminny\\Integrations\\PlaybookResolver;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Crm\\Configuration;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\TeamSettings;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\ContactRoleRepository;\nuse Jiminny\\Repositories\\Crm\\FieldDataRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\Salesforce\\Client;\nuse Jiminny\\Services\\Crm\\Salesforce\\PayloadBuilder;\nuse Jiminny\\Services\\Crm\\Salesforce\\QueryBuilder;\nuse Jiminny\\Services\\Crm\\Salesforce\\QueryHandler;\nuse Jiminny\\Services\\Crm\\Salesforce\\QueryIterator;\nuse Jiminny\\Services\\Crm\\Salesforce\\QueryResults;\nuse Jiminny\\Services\\Crm\\Salesforce\\Service;\nuse PHPUnit\\Framework\\MockObject\\MockObject;\nuse Tests\\TestCase;\nuse Tests\\Unit\\Traits\\TestPrivateMethod;\n\nclass ServiceTest extends TestCase\n{\n use TestPrivateMethod;\n\n public function testFetchAndAssociateRelatedActivity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $payloadBuilder->method('addCustomLogicFieldsPayload')\n ->willReturnCallback(function ($activity, $payload) {\n return $payload;\n });\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods(['fetchRelatedActivity', 'getPlaybook', 'getPlaybookCategory', 'updateRecord'])\n ->getMock();\n\n $serviceMock->expects($this->once())\n ->method('fetchRelatedActivity')\n ->willReturn([\n 'Id' => 'testId',\n 'Type' => null,\n 'OwnerId' => 'testerUser',\n 'Description' => 'Test description',\n ]);\n\n $user = $this->createMock(User::class);\n $team = $this->createMock(Team::class);\n $user->method('getAttribute')->with('team')->willReturn($team);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityField')->willReturn(null);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_EVENT);\n\n $serviceMock->expects($this->once())\n ->method('getPlaybook')\n ->with($user)\n ->willReturn($playbook);\n\n $serviceMock->expects($this->never())\n ->method('getPlaybookCategory');\n\n $serviceMock->expects($this->never())\n ->method('updateRecord');\n\n $fieldDataRepository = $this->createMock(FieldDataRepository::class);\n $fieldDataRepository->method('getActivityFieldData')->willReturn(collect([]));\n app()->instance(FieldDataRepository::class, $fieldDataRepository);\n\n $config = $this->createMock(Configuration::class);\n $profilesRelation = $this->getMockBuilder(\\Illuminate\\Database\\Eloquent\\Relations\\HasMany::class)\n ->disableOriginalConstructor()\n ->onlyMethods(['get'])\n ->addMethods(['where'])\n ->getMock();\n $profilesRelation->method('where')->willReturnSelf();\n $profilesRelation->method('get')->willReturn(collect([]));\n $config->method('profiles')->willReturn($profilesRelation);\n\n $serviceMock->config = $config;\n $serviceMock->profile = null;\n\n $actualStartTime = \\Carbon\\Carbon::now();\n\n $activity = $this->getMockBuilder(Activity::class)\n ->disableOriginalConstructor()\n ->onlyMethods(['update', 'hasProspect'])\n ->getMock();\n\n $activity->method('update')->willReturn(true);\n $activity->method('hasProspect')->willReturn(true);\n\n $activity->type = Activity::TYPE_CONFERENCE;\n $activity->provider = Activity::PROVIDER_TWILIO;\n $activity->lead_id = 1;\n $activity->user_id = 0;\n $activity->id_string = 'test-activity-id';\n $activity->user = $user;\n\n $activity->actual_start_time = $actualStartTime;\n $activity->uuid = 'c53d8320-f556-4cee-a2f8-5f232f454ca4';\n\n app()->bind(PlaybookResolver::class, function () use ($user) {\n $playbook = $this->createMock(Playbook::class);\n $playbookResolver = $this->createMock(PlaybookResolver::class);\n $playbookResolver->expects($this->once())\n ->method('resolvePlaybookByUser')\n ->with($user)\n ->willReturn($playbook);\n\n return $playbookResolver;\n });\n\n $data = $serviceMock->fetchAndAssociateRelatedActivity($activity);\n\n $this->assertInstanceOf(Activity::class, $data);\n $this->assertEquals(Activity::TYPE_CONFERENCE, $data->getType());\n $this->assertEquals($actualStartTime->getTimestamp(), $data->getActualStartTime()->getTimestamp());\n }\n\n public function testFetchAndAssociateRelatedActivitySkipsForTaskBasedPlaybook(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods(['fetchRelatedActivity', 'getPlaybook'])\n ->getMock();\n\n $user = $this->createMock(User::class);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n $playbook->method('getId')->willReturn(123);\n\n $serviceMock->expects($this->once())\n ->method('getPlaybook')\n ->with($user)\n ->willReturn($playbook);\n\n $serviceMock->expects($this->never())\n ->method('fetchRelatedActivity');\n\n $activity = $this->getMockBuilder(Activity::class)\n ->disableOriginalConstructor()\n ->onlyMethods(['hasProspect', 'getUuid'])\n ->getMock();\n\n $activity->method('hasProspect')->willReturn(true);\n $activity->method('getUuid')->willReturn('c53d8320-f556-4cee-a2f8-5f232f454ca4');\n $activity->type = Activity::TYPE_CONFERENCE;\n $activity->actual_start_time = \\Carbon\\Carbon::now();\n $activity->user = $user;\n\n $result = $serviceMock->fetchAndAssociateRelatedActivity($activity);\n\n $this->assertNull($result);\n }\n\n public function testFetchAndAssociateRelatedActivityReturnsNullForNonConference(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $serviceMock = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)\n );\n\n $activity = $this->getMockBuilder(Activity::class)\n ->disableOriginalConstructor()\n ->getMock();\n\n $activity->type = Activity::TYPE_SOFTPHONE;\n\n $result = $serviceMock->fetchAndAssociateRelatedActivity($activity);\n\n $this->assertNull($result);\n }\n\n public function testFetchAndAssociateRelatedActivityReturnsNullWhenNoStartTime(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $serviceMock = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)\n );\n\n\n $activity = $this->getMockBuilder(Activity::class)\n ->disableOriginalConstructor()\n ->getMock();\n\n $activity->type = Activity::TYPE_CONFERENCE;\n $activity->actual_start_time = null;\n $activity->scheduled_start_time = null;\n\n $result = $serviceMock->fetchAndAssociateRelatedActivity($activity);\n\n $this->assertNull($result);\n }\n\n public function testFetchAndAssociateRelatedActivityReturnsNullWhenNoProspect(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods(['getPlaybook'])\n ->getMock();\n\n $serviceMock->expects($this->never())\n ->method('getPlaybook');\n\n $activity = $this->getMockBuilder(Activity::class)\n ->disableOriginalConstructor()\n ->onlyMethods(['hasProspect', 'getUuid'])\n ->getMock();\n\n $activity->method('hasProspect')->willReturn(false);\n $activity->method('getUuid')->willReturn('c53d8320-f556-4cee-a2f8-5f232f454ca4');\n $activity->type = Activity::TYPE_CONFERENCE;\n $activity->actual_start_time = \\Carbon\\Carbon::now();\n\n $result = $serviceMock->fetchAndAssociateRelatedActivity($activity);\n\n $this->assertNull($result);\n }\n\n public function testMatchExactlyByEmail(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods([])\n ->getMock();\n\n $profile = new Profile();\n $profile->setAttribute('id', bin2hex(random_bytes(8)));\n $serviceMock->profile = $profile;\n\n $team = $this->createMock(Team::class);\n $serviceMock->team = $team;\n\n $data = $serviceMock->matchExactlyByEmail(bin2hex(random_bytes(8)) . 'test_email@testserver.com');\n\n $this->assertEquals(null, $data);\n }\n\n public function testMatchDomainFromEmail(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $queryIterator = $this->createMock(QueryIterator::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $config = $this->createMock(Configuration::class);\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('search')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods(['convertCrmData'])\n ->getMock();\n\n $profile = new Profile();\n $profile->account_fields = 'Field1, Field2, Field3';\n $serviceMock->profile = $profile;\n\n $serviceMock->expects($this->once())\n ->method('convertCrmData')\n ->willReturn(['test']);\n\n $this->app->bind(QueryBuilder::class, function () {\n $queryBuilder = $this->createMock(QueryBuilder::class);\n $queryBuilder->expects($this->once())\n ->method('buildMatchByDomainQuery')\n ->with('test_email@testserver.com')\n ->willReturn('FIND {test_email@testserver.com} IN ALL FIELDS RETURNING Account(Id)');\n\n return $queryBuilder;\n });\n\n $team = $this->createMock(Team::class);\n\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n\n $data = $serviceMock->matchByDomain('test_email@testserver.com');\n\n $this->assertEquals(['test'], $data);\n }\n\n public function testBuildTaskSearchFields(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)\n );\n\n $fields = $service->buildTaskSearchFields();\n\n $expectedFields = ['Id', 'WhoId', 'WhatId', 'AccountId'];\n\n $this->assertEquals($expectedFields, $fields);\n }\n\n public function testMapCrmObjects(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class),\n );\n\n $sampleTask = [\n 'WhoId' => '003sampleWhoId',\n 'AccountId' => 'sampleAccountId',\n 'WhatId' => 'sampleWhatId',\n ];\n\n $activityData = $service->mapCrmObjects($sampleTask);\n\n $expectedActivityData = [\n 'contact' => '003sampleWhoId',\n 'account' => 'sampleAccountId',\n 'opportunity' => 'sampleWhatId',\n ];\n\n $this->assertEquals($expectedActivityData, $activityData);\n }\n\n public function testGetInstalledAppVersion(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n $queryIterator = $this->createMock(QueryIterator::class);\n $queryIterator->expects($this->any())\n ->method('current')->willReturn([\n 'SubscriberPackageVersion' => [\n 'MajorVersion' => '1',\n 'MinorVersion' => '0',\n 'PatchVersion' => '1',\n 'BuildNumber' => '0',\n ],\n ]);\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('metadata')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods(array_diff(get_class_methods(Service::class), ['getInstalledAppVersion']))\n ->getMock();\n\n $version = $serviceMock->getInstalledAppVersion();\n\n $this->assertEquals('1010', $version);\n }\n\n public function testSyncProfiles(): void\n {\n $userToSearch = null;\n\n $team = $this->createMock(Team::class);\n $config = $this->createMockedConfiguration();\n $config->expects($this->once())\n ->method('getId')\n ->willReturn(1);\n\n $salesforceUser = [\n 'Email' => 'test@example.com',\n 'UserPreferencesLightningExperiencePreferred' => true,\n 'CallCenterId' => '123',\n 'Id' => '456',\n 'ProfileId' => '789',\n ];\n\n app()->bind(QueryBuilder::class, function () use ($userToSearch) {\n $queryBuilder = $this->createMock(QueryBuilder::class);\n $queryBuilder->expects($this->once())\n ->method('buildGetUsersQuery')\n ->with($userToSearch)\n ->willReturn('SELECT * FROM Users');\n\n return $queryBuilder;\n });\n\n $queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('query')\n ->with('SELECT * FROM Users')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n\n $user = $this->createMock(User::class);\n $user->expects($this->once())\n ->method('getId')\n ->willReturn(123);\n\n $this->mockTeamRepository($team, $salesforceUser, $user);\n\n $profileRepository = $this->createMock(ProfileRepository::class);\n $profileRepository->expects($this->once())\n ->method('updateOrCreateProfile')\n ->with(\n $user,\n [\n 'crm_configuration_id' => 1,\n 'crm_provider_id' => '456',\n ],\n [\n 'user_id' => 123,\n 'edition' => Profile::EDITION_LIGHTNING,\n 'has_external_cti' => true,\n 'crm_profile_id' => '789',\n ]\n )\n ->willReturn(new Profile());\n\n $this->app->instance(ProfileRepository::class, $profileRepository);\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n $result = $serviceMock->syncProfiles($userToSearch);\n\n $this->assertNull($result);\n }\n\n public function testSyncProfilesEmailIsNull(): void\n {\n $userToSearch = $this->createMock(User::class);\n\n $salesforceUser = [\n 'Email' => null,\n ];\n\n app()->bind(QueryBuilder::class, function () {\n $queryBuilder = $this->createMock(QueryBuilder::class);\n $queryBuilder->expects($this->once())\n ->method('buildGetUsersQuery')\n ->with(null)\n ->willReturn('SELECT * FROM Users');\n\n return $queryBuilder;\n });\n\n $queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->exactly(2))\n ->method('query')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n\n $team = $this->createMock(Team::class);\n $user = $this->createMock(User::class);\n $this->mockTeamRepository($team, $salesforceUser, $user, false);\n\n $config = $this->createMock(Configuration::class);\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n\n $profile = $serviceMock->syncProfiles(null);\n\n $this->assertNull($profile);\n }\n\n public function testSyncProfilesUserToSearchMatchesCurrentUser(): void\n {\n $userToSearch = $this->createMock(User::class);\n $userToSearch->expects($this->once())\n ->method('getId')\n ->willReturn(123);\n\n $team = $this->createMock(Team::class);\n $config = $this->createMock(Configuration::class);\n $config->expects($this->once())\n ->method('getId')\n ->willReturn(1);\n\n $salesforceUser = [\n 'Email' => 'test@example.com',\n 'UserPreferencesLightningExperiencePreferred' => true,\n 'CallCenterId' => '123',\n 'Id' => '456',\n 'ProfileId' => '789',\n ];\n\n app()->bind(QueryBuilder::class, function () use ($userToSearch) {\n $queryBuilder = $this->createMock(QueryBuilder::class);\n $queryBuilder->expects($this->once())\n ->method('buildGetUsersQuery')\n ->with($userToSearch)\n ->willReturn('SELECT * FROM Users');\n\n return $queryBuilder;\n });\n\n $queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('query')\n ->with('SELECT * FROM Users')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n\n $user = $this->createMock(User::class);\n $user->expects($this->exactly(2))\n ->method('getId')\n ->willReturn(123);\n\n $this->mockTeamRepository($team, $salesforceUser, $user);\n\n $profileRepository = $this->createMock(ProfileRepository::class);\n $profileRepository->expects($this->once())\n ->method('updateOrCreateProfile')\n ->with(\n $user,\n [\n 'crm_configuration_id' => 1,\n 'crm_provider_id' => '456',\n ],\n [\n 'user_id' => 123,\n 'edition' => Profile::EDITION_LIGHTNING,\n 'has_external_cti' => true,\n 'crm_profile_id' => '789',\n ]\n )\n ->willReturn(new Profile());\n\n $this->app->instance(ProfileRepository::class, $profileRepository);\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n $profile = $serviceMock->syncProfiles($userToSearch);\n\n $this->assertInstanceOf(Profile::class, $profile);\n }\n\n public function testSyncProfilesWithCustomValidation(): void\n {\n $userToSearch = null;\n\n $team = $this->createMock(Team::class);\n $config = $this->createMockedConfiguration();\n $config->expects($this->atLeastOnce()) // Changed from once() to atLeastOnce()\n ->method('getId')\n ->willReturn(1);\n\n $salesforceUser = [\n 'Email' => 'test@example.com',\n 'UserPreferencesLightningExperiencePreferred' => true,\n 'CallCenterId' => '123',\n 'Id' => '456',\n 'ProfileId' => '789',\n 'CustomField' => 'CustomValue',\n ];\n\n $customRules = [\n ['field' => 'CustomField', 'value' => 'CustomValue'],\n ];\n\n $this->mockQueryBuilderAndHandler($userToSearch, [$salesforceUser]);\n\n $user = $this->createMock(User::class);\n $user->expects($this->once())\n ->method('getId')\n ->willReturn(123);\n\n $this->mockTeamRepository($team, $salesforceUser, $user, true, $customRules);\n\n $this->mockProfileRepository($user);\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n $profile = $serviceMock->syncProfiles($userToSearch);\n\n $this->assertNull($profile);\n }\n\n public function testSyncProfilesWithCustomValidationFailing(): void\n {\n $userToSearch = null;\n\n $team = $this->createMock(Team::class);\n $config = $this->createMockedConfiguration();\n\n $salesforceUser = [\n 'Email' => 'test@example.com',\n 'UserPreferencesLightningExperiencePreferred' => true,\n 'CallCenterId' => '123',\n 'Id' => '456',\n 'ProfileId' => '789',\n 'CustomField' => 'WrongValue',\n ];\n\n $customRules = [\n ['field' => 'CustomField', 'value' => 'CustomValue'],\n ];\n\n $this->mockQueryBuilderAndHandler($userToSearch, [$salesforceUser]);\n\n $teamRepository = $this->getMockForAbstractClass(TeamRepository::class, [], '', false, true, true, ['findActiveTeamMemberByEmail', 'getTeamSetting']);\n\n $teamSettings = $this->createMock(TeamSettings::class);\n $teamSettings->method('getValueType')\n ->willReturn('array');\n\n $teamSettings->method('getValue')\n ->willReturn(json_encode($customRules));\n\n $teamRepository->expects($this->once())\n ->method('getTeamSetting')\n ->with($team, 'custom_profile_validation')\n ->willReturn($teamSettings);\n\n app()->bind(TeamRepository::class, function () use ($teamRepository) {\n return $teamRepository;\n });\n\n $profileRepository = $this->createMock(ProfileRepository::class);\n $profileRepository->expects($this->never())\n ->method('updateOrCreateProfile');\n\n $this->app->instance(ProfileRepository::class, $profileRepository);\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n $result = $serviceMock->syncProfiles($userToSearch);\n\n $this->assertNull($result);\n }\n\n public function testGetContactRolesFromCrm(): void\n {\n $contactRoles = [\n [\n 'Id' => '1',\n 'ContactId' => 'Contact1',\n 'OpportunityId' => 'Opportunity1',\n 'Opportunity' => ['OwnerId' => 'Owner1'],\n 'IsPrimary' => true,\n 'Role' => 'Decision Maker',\n ],\n ];\n\n $expectedResponse = [\n [\n 'id' => '1',\n 'contactId' => 'Contact1',\n 'opportunityId' => 'Opportunity1',\n 'ownerId' => 'Owner1',\n 'isPrimary' => true,\n 'role' => 'Decision Maker',\n ],\n ];\n\n $this->bindQueryIterator($contactRoles);\n\n $serviceMock = $this->getServiceMock();\n\n $data = $serviceMock->getContactRolesFromCrm(now()->subDay());\n\n $this->assertEquals($expectedResponse, $data);\n }\n\n public function testGetContactRolesFromCrmNoResult(): void\n {\n $this->bindQueryIterator([]);\n\n $serviceMock = $this->getServiceMock();\n\n $data = $serviceMock->getContactRolesFromCrm(now()->subDay());\n\n $this->assertEquals([], $data);\n }\n\n public function testSyncContactRoles(): void\n {\n $contactRoles = [\n [\n 'id' => '1',\n 'contactId' => 'Contact1',\n 'opportunityId' => 'Opportunity1',\n 'ownerId' => 'Owner1',\n 'isPrimary' => true,\n 'role' => 'Decision Maker',\n ],\n ];\n\n app()->bind(ContactRoleRepository::class, function () {\n $contactRoleRepository = $this->createMock(ContactRoleRepository::class);\n $contactRoleRepository->expects($this->once())\n ->method('saveContactRoles');\n\n return $contactRoleRepository;\n });\n\n $serviceMock = $this->getServiceMock([\n 'getContactRolesFromCrm',\n 'syncRemotelyDeletedContactRoles',\n 'syncContact',\n 'syncOpportunity',\n ]);\n\n $config = $this->createMock(Configuration::class);\n $hasMany = $this->createMock(HasManyExtended::class);\n $hasMany->expects($this->exactly(2))\n ->method('where')\n ->willReturn($hasMany);\n\n $hasMany->expects($this->exactly(2))\n ->method('first')\n ->willReturn(\n $this->createMock(Contact::class),\n $this->createMock(Opportunity::class)\n );\n\n $config->expects($this->once())\n ->method('contacts')\n ->willReturn($hasMany);\n $config->expects($this->once())\n ->method('opportunities')\n ->willReturn($hasMany);\n\n $serviceMock->config = $config;\n\n $serviceMock->expects($this->once())\n ->method('getContactRolesFromCrm')\n ->willReturn($contactRoles);\n\n $serviceMock->expects($this->once())\n ->method('syncRemotelyDeletedContactRoles');\n\n $data = $serviceMock->syncContactRoles(now()->subDay());\n\n $this->assertEquals(1, $data);\n }\n\n public function testSyncRemotelyDeletedContactRoles(): void\n {\n $contactRoles = [\n [\n 'id' => '1',\n 'crm_provider_id' => '1',\n ],\n ];\n\n app()->bind(QueryHandler::class, function () use ($contactRoles) {\n $queryResults = new QueryResults($contactRoles, 1, true, null);\n\n $handler = $this->createMock(QueryHandler::class);\n $handler->method('queryDeleted')\n ->willReturn($queryResults);\n\n return $handler;\n });\n\n app()->bind(ContactRoleRepository::class, function () {\n $contactRoleRepository = $this->createMock(ContactRoleRepository::class);\n $contactRoleRepository->expects($this->once())\n ->method('deleteContactRoles');\n\n return $contactRoleRepository;\n });\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $this->createMock(Team::class);\n\n $data = $this->invokePrivateMethod('syncRemotelyDeletedContactRoles', $serviceMock, []);\n\n $this->assertTrue($data);\n }\n\n private function bindQueryIterator(array $queryResult): void\n {\n /** @var Client $client */\n $client = $this->createMock(Client::class);\n $queryIterator = new QueryIterator(\n $client,\n new QueryResults($queryResult, 1, true, null)\n );\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('query')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n }\n\n public static function getOpportunitySortOrderDataProvider(): array\n {\n return [\n 'all open recently updated' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED, ['LastModifiedDate DESC', true]],\n 'all open recently created' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED, ['CreatedDate DESC', true]],\n 'all open oldest created' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED, ['CreatedDate ASC', true]],\n 'all recently updated' => [Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED, ['LastModifiedDate DESC', false]],\n 'default' => ['unknown', ['LastModifiedDate DESC', true]],\n ];\n }\n\n private function createMockedConfiguration(): Configuration\n {\n $config = $this->createMock(Configuration::class);\n $profilesRelation = $this->getMockBuilder(\\Illuminate\\Database\\Eloquent\\Relations\\HasMany::class)\n ->disableOriginalConstructor()\n ->onlyMethods(['get'])\n ->addMethods(['where', 'first'])\n ->getMock();\n $profilesRelation->method('where')->willReturnSelf();\n $profilesRelation->method('get')->willReturn(collect([]));\n $profilesRelation->method('first')->willReturn(null);\n $config->method('profiles')->willReturn($profilesRelation);\n\n return $config;\n }\n\n private function getServiceMock(array $onlyMethods = []): MockObject&Service\n {\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $this->createMock(Client::class),\n $this->createMock(PayloadBuilder::class),\n $this->createMock(Dispatcher::class),\n $this->createMock(CountriesMap::class),\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods($onlyMethods)\n ->getMock();\n\n $serviceMock->profile = $this->createMock(Profile::class);\n\n return $serviceMock;\n }\n\n private function mockQueryBuilderAndHandler($userToSearch, $salesforceUsers): void\n {\n app()->bind(QueryBuilder::class, function () use ($userToSearch) {\n $queryBuilder = $this->createMock(QueryBuilder::class);\n $queryBuilder->expects($this->once())\n ->method('buildGetUsersQuery')\n ->with($userToSearch)\n ->willReturn('SELECT * FROM Users');\n\n return $queryBuilder;\n });\n\n $queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults($salesforceUsers, count($salesforceUsers), true, null));\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('query')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n }\n\n private function mockTeamRepository(\n Team $team,\n array $salesforceUser,\n ?User $user = null,\n bool $userSearch = true,\n array $customRules = []\n ): void {\n $teamRepository = $this->getMockForAbstractClass(TeamRepository::class, [], '', false, true, true, ['findActiveTeamMemberByEmail', 'getTeamSetting']);\n\n if ($userSearch) {\n $teamRepository->expects($this->once())\n ->method('findActiveTeamMemberByEmail')\n ->with($team, $salesforceUser['Email'])\n ->willReturn($user);\n }\n\n $teamSettings = $this->createMock(TeamSettings::class);\n $teamSettings->method('getValueType')\n ->willReturn('array');\n\n $teamSettings->method('getValue')\n ->willReturn(json_encode($customRules));\n\n $teamRepository->expects($this->once())\n ->method('getTeamSetting')\n ->with($team, 'custom_profile_validation')\n ->willReturn($teamSettings);\n\n app()->bind(TeamRepository::class, function () use ($teamRepository) {\n return $teamRepository;\n });\n }\n\n private function mockProfileRepository(User $user): void\n {\n $profileRepository = $this->createMock(ProfileRepository::class);\n $profileRepository->expects($this->once())\n ->method('updateOrCreateProfile')\n ->with(\n $user,\n [\n 'crm_configuration_id' => 1,\n 'crm_provider_id' => '456',\n ],\n [\n 'user_id' => 123,\n 'edition' => Profile::EDITION_LIGHTNING,\n 'has_external_cti' => true,\n 'crm_profile_id' => '789',\n ]\n )\n ->willReturn(new Profile());\n\n $this->app->instance(ProfileRepository::class, $profileRepository);\n }\n\n public function testBuildEnhancedNoteDecodesHtmlEntities(): void\n {\n $service = $this->getServiceMock(['createRecord']);\n\n $profile = new Profile();\n $profile->setAttribute('crm_provider_id', 'owner-123');\n $service->profile = $profile;\n\n $service->expects($this->exactly(2))\n ->method('createRecord')\n ->willReturnOnConsecutiveCalls('note-id-123', 'link-id-456');\n\n $bodyWithEntities = 'Welch's current challenges and Facebook's Club';\n\n $result = $this->invokePrivateMethod('buildEnhancedNote', $service, [\n 'Test Title',\n $bodyWithEntities,\n 'object-id-789',\n ]);\n\n $this->assertEquals('note-id-123', $result);\n }\n\n public function testBuildEnhancedNoteSanitizesWithoutQuotes(): void\n {\n $service = $this->getServiceMock(['createRecord']);\n\n $profile = new Profile();\n $profile->setAttribute('crm_provider_id', 'owner-456');\n $service->profile = $profile;\n\n $service->expects($this->exactly(2))\n ->method('createRecord')\n ->willReturnCallback(function ($type, $data) {\n if ($type === 'ContentNote') {\n $decoded = base64_decode($data['Content']);\n $this->assertStringContainsString(\"Welch's\", $decoded);\n $this->assertStringNotContainsString(''', $decoded);\n $this->assertStringNotContainsString('&#039;', $decoded);\n $this->assertStringContainsString('<script>', $decoded);\n\n return 'note-id-456';\n }\n\n return 'link-id-789';\n });\n\n $bodyWithMixedContent = \"Welch's and <script>alert('xss')</script>\";\n\n $result = $this->invokePrivateMethod('buildEnhancedNote', $service, [\n 'Test Title',\n $bodyWithMixedContent,\n 'object-id-123',\n ]);\n\n $this->assertEquals('note-id-456', $result);\n }\n\n public function testBuildEnhancedNoteConvertsLineBreaks(): void\n {\n $service = $this->getServiceMock(['createRecord']);\n\n $profile = new Profile();\n $profile->setAttribute('crm_provider_id', 'owner-789');\n $service->profile = $profile;\n\n $service->expects($this->exactly(2))\n ->method('createRecord')\n ->willReturnCallback(function ($type, $data) {\n if ($type === 'ContentNote') {\n $decoded = base64_decode($data['Content']);\n $this->assertStringContainsString('<br>', $decoded);\n $this->assertStringNotContainsString('<br />', $decoded);\n\n return 'note-id-789';\n }\n\n return 'link-id-012';\n });\n\n $bodyWithLineBreaks = \"Line 1\\nLine 2\\nLine 3\";\n\n $result = $this->invokePrivateMethod('buildEnhancedNote', $service, [\n 'Test Title',\n $bodyWithLineBreaks,\n 'object-id-456',\n ]);\n\n $this->assertEquals('note-id-789', $result);\n }\n\n public function testBuildEnhancedNoteHandlesComplexScenario(): void\n {\n $service = $this->getServiceMock(['createRecord']);\n\n $profile = new Profile();\n $profile->setAttribute('crm_provider_id', 'owner-complex');\n $service->profile = $profile;\n\n $service->expects($this->exactly(2))\n ->method('createRecord')\n ->willReturnCallback(function ($type, $data) {\n if ($type === 'ContentNote') {\n $decoded = base64_decode($data['Content']);\n\n $this->assertStringContainsString(\"Welch's\", $decoded);\n $this->assertStringContainsString(\"Facebook's Club\", $decoded);\n $this->assertStringContainsString(\"Arctics'\", $decoded);\n $this->assertStringNotContainsString(''', $decoded);\n $this->assertStringNotContainsString('&#039;', $decoded);\n $this->assertStringContainsString('<br>', $decoded);\n $this->assertStringContainsString('<', $decoded);\n $this->assertStringContainsString('>', $decoded);\n\n return 'note-complex';\n }\n\n return 'link-complex';\n });\n\n $complexBody = \"Summary:\\n---------\\nThe call focused on understanding Welch's current challenges and exploring how Arctics' Revenue Growth Management solutions could support their strategic goals.\\n\\n• John SMith discussed his role as a category advisor for Google and Facebook's Club, emphasizing the importance of market research and advising on product assortment.\\n• Madona introduced Arctics' Virtual Shoppers AI, which simulates consumer <behavior> to optimize pricing and promotional strategies.\";\n\n $result = $this->invokePrivateMethod('buildEnhancedNote', $service, [\n 'Jiminny Transcription Summary',\n $complexBody,\n 'task-id-001',\n ]);\n\n $this->assertEquals('note-complex', $result);\n }\n\n public function testSyncRemotelyDeletedObjectsWithErrorHandlingSuccess(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['syncRemotelyDeletedObjects'])\n ->getMock();\n\n // Mock team\n $team = $this->createMock(Team::class);\n $team->method('getUuid')->willReturn('team-uuid-123');\n $serviceMock->team = $team;\n\n // Expect syncRemotelyDeletedObjects to be called once and succeed\n $serviceMock->expects($this->once())\n ->method('syncRemotelyDeletedObjects')\n ->with(\\Jiminny\\Enums\\CrmObject::ACCOUNT);\n\n // Call the protected method using reflection\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');\n $method->setAccessible(true);\n\n // Should not throw any exceptions\n $method->invoke($serviceMock, \\Jiminny\\Enums\\CrmObject::ACCOUNT);\n\n $this->assertTrue(true); // Test completed successfully\n }\n\n public function testSyncRemotelyDeletedObjectsWithErrorHandlingFailure(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['syncRemotelyDeletedObjects'])\n ->getMock();\n\n // Mock team\n $team = $this->createMock(Team::class);\n $team->method('getUuid')->willReturn('team-uuid-456');\n $serviceMock->team = $team;\n\n // Mock logger to verify warning is logged\n $logger = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n\n // Use reflection to set the protected logger property\n $reflection = new \\ReflectionClass($serviceMock);\n $loggerProperty = $reflection->getProperty('logger');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($serviceMock, $logger);\n\n $exception = new \\Exception('Sync failed due to API error');\n\n // Expect syncRemotelyDeletedObjects to throw an exception\n $serviceMock->expects($this->once())\n ->method('syncRemotelyDeletedObjects')\n ->with(\\Jiminny\\Enums\\CrmObject::CONTACT)\n ->willThrowException($exception);\n\n // Expect warning to be logged with correct message and parameters\n $logger->expects($this->once())\n ->method('warning')\n ->with(\n '[Salesforce] Remotely deleted objects sync failed',\n [\n 'objectType' => 'contact',\n 'teamId' => 'team-uuid-456',\n 'reason' => 'Sync failed due to API error',\n ]\n );\n\n // Call the protected method using reflection\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');\n $method->setAccessible(true);\n\n // Should not re-throw the exception, just log it\n $method->invoke($serviceMock, \\Jiminny\\Enums\\CrmObject::CONTACT);\n\n $this->assertTrue(true); // Test completed successfully\n }\n\n public function testSyncRemotelyDeletedObjectsWithErrorHandlingWithLogParams(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['syncRemotelyDeletedObjects'])\n ->getMock();\n\n // Mock team\n $team = $this->createMock(Team::class);\n $team->method('getUuid')->willReturn('team-uuid-789');\n $serviceMock->team = $team;\n\n // Mock logger to verify warning is logged\n $logger = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n\n // Use reflection to set the protected logger property\n $loggerReflection = new \\ReflectionClass($serviceMock);\n $loggerProperty = $loggerReflection->getProperty('logger');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($serviceMock, $logger);\n\n $exception = new \\Exception('Network timeout');\n\n // Expect syncRemotelyDeletedObjects to throw an exception\n $serviceMock->expects($this->once())\n ->method('syncRemotelyDeletedObjects')\n ->with(\\Jiminny\\Enums\\CrmObject::OPPORTUNITY)\n ->willThrowException($exception);\n\n // Additional log parameters\n $logParams = [\n 'syncType' => 'full',\n 'batchSize' => 100,\n ];\n\n // Expect warning to be logged with merged parameters\n $logger->expects($this->once())\n ->method('warning')\n ->with(\n '[Salesforce] Remotely deleted objects sync failed',\n [\n 'objectType' => 'opportunity',\n 'teamId' => 'team-uuid-789',\n 'reason' => 'Network timeout',\n 'syncType' => 'full',\n 'batchSize' => 100,\n ]\n );\n\n // Call the protected method using reflection\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');\n $method->setAccessible(true);\n\n // Should not re-throw the exception, just log it\n $method->invoke($serviceMock, \\Jiminny\\Enums\\CrmObject::OPPORTUNITY, $logParams);\n\n $this->assertTrue(true); // Test completed successfully\n }\n\n /**\n * @dataProvider crmObjectProvider\n */\n public function testSyncRemotelyDeletedObjectsWithErrorHandlingDifferentCrmObjects(\\Jiminny\\Enums\\CrmObject $crmObject): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['syncRemotelyDeletedObjects'])\n ->getMock();\n\n // Mock team\n $team = $this->createMock(Team::class);\n $team->method('getUuid')->willReturn('team-uuid-test');\n $serviceMock->team = $team;\n\n // Mock logger to verify warning is logged\n $logger = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n\n // Use reflection to set the protected logger property\n $loggerReflectionClass = new \\ReflectionClass($serviceMock);\n $loggerProperty = $loggerReflectionClass->getProperty('logger');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($serviceMock, $logger);\n\n $exception = new \\Exception('Test error');\n\n // Expect syncRemotelyDeletedObjects to throw an exception\n $serviceMock->expects($this->once())\n ->method('syncRemotelyDeletedObjects')\n ->with($crmObject)\n ->willThrowException($exception);\n\n // Expect warning to be logged with correct entity type\n $logger->expects($this->once())\n ->method('warning')\n ->with(\n '[Salesforce] Remotely deleted objects sync failed',\n [\n 'objectType' => $crmObject->value,\n 'teamId' => 'team-uuid-test',\n 'reason' => 'Test error',\n ]\n );\n\n // Call the protected method using reflection\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');\n $method->setAccessible(true);\n\n $method->invoke($serviceMock, $crmObject);\n\n $this->assertTrue(true); // Test completed successfully\n }\n\n public function testHandleObjectDeletionWithDeletedEntity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['deleteCrmObject'])\n ->getMock();\n\n $entity = $this->createMock(\\Jiminny\\Models\\Account::class);\n $crmData = ['IsDeleted' => true];\n\n $serviceMock->expects($this->once())\n ->method('deleteCrmObject')\n ->with($entity);\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('handleObjectDeletion');\n $method->setAccessible(true);\n\n $method->invoke($serviceMock, $entity, $crmData);\n }\n\n public function testHandleObjectDeletionWithNonDeletedEntity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['deleteCrmObject'])\n ->getMock();\n\n $entity = $this->createMock(\\Jiminny\\Models\\Contact::class);\n $crmData = ['IsDeleted' => false];\n\n $serviceMock->expects($this->never())\n ->method('deleteCrmObject');\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('handleObjectDeletion');\n $method->setAccessible(true);\n\n $method->invoke($serviceMock, $entity, $crmData);\n }\n\n public function testDeleteCrmObjectWithValidEntity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['dispatchDeleteCrmObjectJob'])\n ->getMock();\n\n $entity = $this->createMock(\\Jiminny\\Models\\Lead::class);\n $entity->expects($this->once())->method('delete');\n\n $serviceMock->expects($this->once())\n ->method('dispatchDeleteCrmObjectJob')\n ->with($entity);\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('deleteCrmObject');\n $method->setAccessible(true);\n\n $method->invoke($serviceMock, $entity);\n }\n\n public function testDeleteCrmObjectWithNullEntity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['dispatchDeleteCrmObjectJob'])\n ->getMock();\n\n $serviceMock->expects($this->never())\n ->method('dispatchDeleteCrmObjectJob');\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('deleteCrmObject');\n $method->setAccessible(true);\n\n $method->invoke($serviceMock, null);\n }\n\n public function testDispatchDeleteCrmObjectJobWithNullEntity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($service);\n $method = $reflection->getMethod('dispatchDeleteCrmObjectJob');\n $method->setAccessible(true);\n\n // Should return early without dispatching - no exception expected\n $method->invoke($service, null);\n\n $this->assertTrue(true); // Test completed successfully\n }\n\n public function testDispatchDeleteCrmObjectJobWithUnsupportedEntity(): void\n {\n $this->expectException(\\TypeError::class);\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n $unsupportedEntity = $this->createMock(\\stdClass::class);\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($service);\n $method = $reflection->getMethod('dispatchDeleteCrmObjectJob');\n\n // This will throw TypeError due to union type constraint\n $method->invoke($service, $unsupportedEntity);\n }\n\n public function testHandleEntityDeletionByProviderIdMethodExists(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n // Test that the method exists and is accessible via reflection\n $reflection = new \\ReflectionClass($service);\n $method = $reflection->getMethod('handleEntityDeletionByProviderId');\n $method->setAccessible(true);\n\n // Verify method exists and has correct parameters\n $this->assertTrue($method->isProtected());\n $this->assertEquals(2, $method->getNumberOfParameters());\n\n $parameters = $method->getParameters();\n $this->assertEquals('targetEntity', $parameters[0]->getName());\n $this->assertEquals('crmData', $parameters[1]->getName());\n }\n\n public function testSyncRemotelyDeletedObjectsWithNoResults(): void\n {\n // Create a real service instance to avoid mock property issues\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n // Mock queryHandler to throw NoResultsException\n $queryHandler = $this->createMock(QueryHandler::class);\n $queryHandler->expects($this->once())\n ->method('queryDeleted')\n ->with('Opportunity')\n ->willThrowException(new NoResultsException('No results'));\n\n // Set the queryHandler using reflection on the real service\n $reflection = new \\ReflectionClass($service);\n $queryHandlerProperty = $reflection->getProperty('queryHandler');\n $queryHandlerProperty->setAccessible(true);\n $queryHandlerProperty->setValue($service, $queryHandler);\n\n $result = self::invokePrivateMethod('syncRemotelyDeletedObjects', $service, [CrmObject::OPPORTUNITY]);\n\n $this->assertFalse($result);\n }\n\n public function testSyncRemotelyDeletedObjectsWithEmptyResults(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n // Mock queryHandler to return empty results\n $queryResult = $this->createMock(QueryResults::class);\n $queryResult->method('getResults')->willReturn([]);\n\n $queryHandler = $this->createMock(QueryHandler::class);\n $queryHandler->expects($this->once())\n ->method('queryDeleted')\n ->with('Opportunity')\n ->willReturn($queryResult);\n\n // Set the queryHandler using reflection on the real service\n $reflection = new \\ReflectionClass($service);\n $queryHandlerProperty = $reflection->getProperty('queryHandler');\n $queryHandlerProperty->setAccessible(true);\n $queryHandlerProperty->setValue($service, $queryHandler);\n\n $result = self::invokePrivateMethod('syncRemotelyDeletedObjects', $service, [CrmObject::OPPORTUNITY]);\n\n $this->assertFalse($result);\n }\n\n public function testSyncRemotelyDeletedObjectsWithUnsupportedCrmObject(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n // Mock queryHandler to return some deleted objects so we reach the match statement\n $deletedObjects = [\n ['id' => 'task1'],\n ['id' => 'task2'],\n ];\n $queryResult = $this->createMock(QueryResults::class);\n $queryResult->method('getResults')->willReturn($deletedObjects);\n\n $queryHandler = $this->createMock(QueryHandler::class);\n $queryHandler->expects($this->once())\n ->method('queryDeleted')\n ->with('Task') // ucfirst('task') = 'Task'\n ->willReturn($queryResult);\n\n self::setPrivateProperty($service, 'queryHandler', $queryHandler);\n\n $this->expectException(InvalidArgumentException::class);\n $this->expectExceptionMessage('Unsupported CrmObject: task');\n\n self::invokePrivateMethod('syncRemotelyDeletedObjects', $service, [CrmObject::TASK]);\n }\n\n public static function crmObjectProvider(): array\n {\n return [\n 'Account' => [CrmObject::ACCOUNT],\n 'Contact' => [CrmObject::CONTACT],\n 'Lead' => [CrmObject::LEAD],\n 'Opportunity' => [CrmObject::OPPORTUNITY],\n ];\n }\n\n public function testVerifyTaskExistsReturnsTrue(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-123', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-123');\n $activity->method('getId')->willReturn(456);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-123', ['Id', 'IsDeleted'])\n ->willReturn(['Id' => 'task-123', 'IsDeleted' => false]);\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertTrue($result);\n }\n\n public function testVerifyTaskExistsReturnsTrueForEvent(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:event-123', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('event-123');\n $activity->method('getId')->willReturn(456);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_EVENT);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Event', 'event-123', ['Id', 'IsDeleted'])\n ->willReturn(['Id' => 'event-123', 'IsDeleted' => false]);\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertTrue($result);\n }\n\n public function testVerifyTaskExistsReturnsFalseWhenDeleted(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-456', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-456');\n $activity->method('getId')->willReturn(789);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-456', ['Id', 'IsDeleted'])\n ->willReturn(['Id' => 'task-456', 'IsDeleted' => true]);\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertFalse($result);\n }\n\n public function testVerifyTaskExistsReturnsFalseWhenNotFound(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-999', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-999');\n $activity->method('getId')->willReturn(999);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-999', ['Id', 'IsDeleted'])\n ->willThrowException(new \\Jiminny\\Exceptions\\HttpNotFoundException('Task not found'));\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertFalse($result);\n }\n\n public function testVerifyTaskExistsReturnsFalseWhenNoPlaybook(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-no-playbook', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-no-playbook');\n $activity->method('getId')->willReturn(111);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn(null);\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertFalse($result);\n }\n\n public function testVerifyTaskExistsThrowsExceptionForTransientErrors(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-error', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-error');\n $activity->method('getId')->willReturn(888);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-error', ['Id', 'IsDeleted'])\n ->willThrowException(new \\RuntimeException('Network timeout'));\n\n $this->expectException(\\RuntimeException::class);\n $this->expectExceptionMessage('Network timeout');\n\n $service->verifyTaskExists($activity);\n }\n\n public function testVerifyTaskExistsCachesResults(): void\n {\n $cachedValue = null;\n Cache::shouldReceive('remember')\n ->twice()\n ->with('crm_task_exists:123:task-cached', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(function ($key, $ttl, $callback) use (&$cachedValue) {\n if ($cachedValue === null) {\n $cachedValue = $callback();\n }\n\n return $cachedValue;\n });\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-cached');\n $activity->method('getId')->willReturn(555);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-cached', ['Id', 'IsDeleted'])\n ->willReturn(['Id' => 'task-cached', 'IsDeleted' => false]);\n\n $result1 = $service->verifyTaskExists($activity);\n $result2 = $service->verifyTaskExists($activity);\n\n $this->assertTrue($result1);\n $this->assertTrue($result2);\n }\n\n public function testVerifyTaskExistsReturnsFalseForHttpBadRequestException(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-400', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-400');\n $activity->method('getId')->willReturn(400);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-400', ['Id', 'IsDeleted'])\n ->willThrowException(new \\Jiminny\\Exceptions\\HttpBadRequestException('Bad request'));\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertFalse($result);\n }\n\n public function testImportOpportunitySkipsWhenNoProfileAndNoAccount(): void\n {\n $crmData = [\n 'Id' => 'SF-NO-USER-1',\n 'Name' => 'Test Opportunity',\n 'OwnerId' => 'owner-no-profile',\n // No AccountId\n ];\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(\\Illuminate\\Events\\Dispatcher::class); // ← ADD THIS\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n\n $service = new Service(\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService\n );\n\n $config = $this->createMock(Configuration::class);\n\n // Mock profiles relation returning null (no profile found)\n $profilesRelation = \\Mockery::mock(\\Illuminate\\Database\\Eloquent\\Relations\\HasMany::class);\n $profilesRelation->shouldReceive('where')->with('crm_provider_id', 'owner-no-profile')->andReturnSelf();\n $profilesRelation->shouldReceive('first')->andReturn(null);\n\n $config->expects($this->once())\n ->method('profiles')\n ->willReturn($profilesRelation);\n\n $team = $this->createMock(Team::class);\n $team->method('getId')->willReturn(1);\n\n $logger = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n $logger->expects($this->once())\n ->method('error')\n ->with(\n '[Salesforce] | Skip import, no user_id found',\n ['id' => 'SF-NO-USER-1']\n );\n\n $reflection = new \\ReflectionClass($service);\n\n $configProperty = $reflection->getProperty('config');\n $configProperty->setAccessible(true);\n $configProperty->setValue($service, $config);\n\n $teamProperty = $reflection->getProperty('team');\n $teamProperty->setAccessible(true);\n $teamProperty->setValue($service, $team);\n\n $loggerProperty = $reflection->getProperty('logger');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($service, $logger);\n\n // Initialize profile property to avoid \"must not be accessed before initialization\" error\n $profileProperty = $reflection->getProperty('profile');\n $profileProperty->setAccessible(true);\n $profileProperty->setValue($service, null);\n\n $result = self::invokePrivateMethod('importOpportunity', $service, [$crmData]);\n\n $this->assertNull($result);\n }\n\n public function testImportOpportunitySkipsWhenProfileNotFound(): void\n {\n $crmData = [\n 'Id' => 'SF-NO-USER-2',\n 'Name' => 'Test Opportunity',\n 'OwnerId' => 'owner-not-found',\n // No AccountId - avoid complex account processing\n ];\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $eventDispatcher = $this->createMock(\\Illuminate\\Events\\Dispatcher::class); // ← ADD THIS\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n\n\n $service = new Service(\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService\n );\n\n $config = $this->createMock(Configuration::class);\n\n // Mock profiles relation returning null (no profile found)\n $profilesRelation = \\Mockery::mock(\\Illuminate\\Database\\Eloquent\\Relations\\HasMany::class);\n $profilesRelation->shouldReceive('where')->with('crm_provider_id', 'owner-not-found')->andReturnSelf();\n $profilesRelation->shouldReceive('first')->andReturn(null);\n\n $config->expects($this->once())\n ->method('profiles')\n ->willReturn($profilesRelation);\n\n $team = $this->createMock(Team::class);\n $team->method('getId')->willReturn(1);\n\n $logger = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n $logger->expects($this->once())\n ->method('error')\n ->with(\n '[Salesforce] | Skip import, no user_id found',\n ['id' => 'SF-NO-USER-2']\n );\n\n $reflection = new \\ReflectionClass($service);\n\n $configProperty = $reflection->getProperty('config');\n $configProperty->setAccessible(true);\n $configProperty->setValue($service, $config);\n\n $teamProperty = $reflection->getProperty('team');\n $teamProperty->setAccessible(true);\n $teamProperty->setValue($service, $team);\n\n $loggerProperty = $reflection->getProperty('logger');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($service, $logger);\n\n // Initialize profile property\n $profileProperty = $reflection->getProperty('profile');\n $profileProperty->setAccessible(true);\n $profileProperty->setValue($service, null);\n\n $result = self::invokePrivateMethod('importOpportunity', $service, [$crmData]);\n\n $this->assertNull($result);\n }\n}\n\nclass HasManyExtended extends HasMany\n{\n public function where()\n {\n }\n\n public function first()\n {\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Services\\Crm\\Salesforce;\n\nuse Illuminate\\Database\\Eloquent\\Relations\\HasMany;\nuse Illuminate\\Events\\Dispatcher;\nuse Illuminate\\Support\\Facades\\Cache;\nuse Jiminny\\Component\\Country\\CountriesMap;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Enums\\CrmObject;\nuse Jiminny\\Exceptions\\InvalidArgumentException;\nuse Jiminny\\Exceptions\\NoResultsException;\nuse Jiminny\\Integrations\\PlaybookResolver;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Crm\\Configuration;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\TeamSettings;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\ContactRoleRepository;\nuse Jiminny\\Repositories\\Crm\\FieldDataRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\Salesforce\\Client;\nuse Jiminny\\Services\\Crm\\Salesforce\\PayloadBuilder;\nuse Jiminny\\Services\\Crm\\Salesforce\\QueryBuilder;\nuse Jiminny\\Services\\Crm\\Salesforce\\QueryHandler;\nuse Jiminny\\Services\\Crm\\Salesforce\\QueryIterator;\nuse Jiminny\\Services\\Crm\\Salesforce\\QueryResults;\nuse Jiminny\\Services\\Crm\\Salesforce\\Service;\nuse PHPUnit\\Framework\\MockObject\\MockObject;\nuse Tests\\TestCase;\nuse Tests\\Unit\\Traits\\TestPrivateMethod;\n\nclass ServiceTest extends TestCase\n{\n use TestPrivateMethod;\n\n public function testFetchAndAssociateRelatedActivity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $payloadBuilder->method('addCustomLogicFieldsPayload')\n ->willReturnCallback(function ($activity, $payload) {\n return $payload;\n });\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods(['fetchRelatedActivity', 'getPlaybook', 'getPlaybookCategory', 'updateRecord'])\n ->getMock();\n\n $serviceMock->expects($this->once())\n ->method('fetchRelatedActivity')\n ->willReturn([\n 'Id' => 'testId',\n 'Type' => null,\n 'OwnerId' => 'testerUser',\n 'Description' => 'Test description',\n ]);\n\n $user = $this->createMock(User::class);\n $team = $this->createMock(Team::class);\n $user->method('getAttribute')->with('team')->willReturn($team);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityField')->willReturn(null);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_EVENT);\n\n $serviceMock->expects($this->once())\n ->method('getPlaybook')\n ->with($user)\n ->willReturn($playbook);\n\n $serviceMock->expects($this->never())\n ->method('getPlaybookCategory');\n\n $serviceMock->expects($this->never())\n ->method('updateRecord');\n\n $fieldDataRepository = $this->createMock(FieldDataRepository::class);\n $fieldDataRepository->method('getActivityFieldData')->willReturn(collect([]));\n app()->instance(FieldDataRepository::class, $fieldDataRepository);\n\n $config = $this->createMock(Configuration::class);\n $profilesRelation = $this->getMockBuilder(\\Illuminate\\Database\\Eloquent\\Relations\\HasMany::class)\n ->disableOriginalConstructor()\n ->onlyMethods(['get'])\n ->addMethods(['where'])\n ->getMock();\n $profilesRelation->method('where')->willReturnSelf();\n $profilesRelation->method('get')->willReturn(collect([]));\n $config->method('profiles')->willReturn($profilesRelation);\n\n $serviceMock->config = $config;\n $serviceMock->profile = null;\n\n $actualStartTime = \\Carbon\\Carbon::now();\n\n $activity = $this->getMockBuilder(Activity::class)\n ->disableOriginalConstructor()\n ->onlyMethods(['update', 'hasProspect'])\n ->getMock();\n\n $activity->method('update')->willReturn(true);\n $activity->method('hasProspect')->willReturn(true);\n\n $activity->type = Activity::TYPE_CONFERENCE;\n $activity->provider = Activity::PROVIDER_TWILIO;\n $activity->lead_id = 1;\n $activity->user_id = 0;\n $activity->id_string = 'test-activity-id';\n $activity->user = $user;\n\n $activity->actual_start_time = $actualStartTime;\n $activity->uuid = 'c53d8320-f556-4cee-a2f8-5f232f454ca4';\n\n app()->bind(PlaybookResolver::class, function () use ($user) {\n $playbook = $this->createMock(Playbook::class);\n $playbookResolver = $this->createMock(PlaybookResolver::class);\n $playbookResolver->expects($this->once())\n ->method('resolvePlaybookByUser')\n ->with($user)\n ->willReturn($playbook);\n\n return $playbookResolver;\n });\n\n $data = $serviceMock->fetchAndAssociateRelatedActivity($activity);\n\n $this->assertInstanceOf(Activity::class, $data);\n $this->assertEquals(Activity::TYPE_CONFERENCE, $data->getType());\n $this->assertEquals($actualStartTime->getTimestamp(), $data->getActualStartTime()->getTimestamp());\n }\n\n public function testFetchAndAssociateRelatedActivitySkipsForTaskBasedPlaybook(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods(['fetchRelatedActivity', 'getPlaybook'])\n ->getMock();\n\n $user = $this->createMock(User::class);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n $playbook->method('getId')->willReturn(123);\n\n $serviceMock->expects($this->once())\n ->method('getPlaybook')\n ->with($user)\n ->willReturn($playbook);\n\n $serviceMock->expects($this->never())\n ->method('fetchRelatedActivity');\n\n $activity = $this->getMockBuilder(Activity::class)\n ->disableOriginalConstructor()\n ->onlyMethods(['hasProspect', 'getUuid'])\n ->getMock();\n\n $activity->method('hasProspect')->willReturn(true);\n $activity->method('getUuid')->willReturn('c53d8320-f556-4cee-a2f8-5f232f454ca4');\n $activity->type = Activity::TYPE_CONFERENCE;\n $activity->actual_start_time = \\Carbon\\Carbon::now();\n $activity->user = $user;\n\n $result = $serviceMock->fetchAndAssociateRelatedActivity($activity);\n\n $this->assertNull($result);\n }\n\n public function testFetchAndAssociateRelatedActivityReturnsNullForNonConference(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $serviceMock = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)\n );\n\n $activity = $this->getMockBuilder(Activity::class)\n ->disableOriginalConstructor()\n ->getMock();\n\n $activity->type = Activity::TYPE_SOFTPHONE;\n\n $result = $serviceMock->fetchAndAssociateRelatedActivity($activity);\n\n $this->assertNull($result);\n }\n\n public function testFetchAndAssociateRelatedActivityReturnsNullWhenNoStartTime(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $serviceMock = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)\n );\n\n\n $activity = $this->getMockBuilder(Activity::class)\n ->disableOriginalConstructor()\n ->getMock();\n\n $activity->type = Activity::TYPE_CONFERENCE;\n $activity->actual_start_time = null;\n $activity->scheduled_start_time = null;\n\n $result = $serviceMock->fetchAndAssociateRelatedActivity($activity);\n\n $this->assertNull($result);\n }\n\n public function testFetchAndAssociateRelatedActivityReturnsNullWhenNoProspect(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods(['getPlaybook'])\n ->getMock();\n\n $serviceMock->expects($this->never())\n ->method('getPlaybook');\n\n $activity = $this->getMockBuilder(Activity::class)\n ->disableOriginalConstructor()\n ->onlyMethods(['hasProspect', 'getUuid'])\n ->getMock();\n\n $activity->method('hasProspect')->willReturn(false);\n $activity->method('getUuid')->willReturn('c53d8320-f556-4cee-a2f8-5f232f454ca4');\n $activity->type = Activity::TYPE_CONFERENCE;\n $activity->actual_start_time = \\Carbon\\Carbon::now();\n\n $result = $serviceMock->fetchAndAssociateRelatedActivity($activity);\n\n $this->assertNull($result);\n }\n\n public function testMatchExactlyByEmail(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods([])\n ->getMock();\n\n $profile = new Profile();\n $profile->setAttribute('id', bin2hex(random_bytes(8)));\n $serviceMock->profile = $profile;\n\n $team = $this->createMock(Team::class);\n $serviceMock->team = $team;\n\n $data = $serviceMock->matchExactlyByEmail(bin2hex(random_bytes(8)) . 'test_email@testserver.com');\n\n $this->assertEquals(null, $data);\n }\n\n public function testMatchDomainFromEmail(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $queryIterator = $this->createMock(QueryIterator::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $config = $this->createMock(Configuration::class);\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('search')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods(['convertCrmData'])\n ->getMock();\n\n $profile = new Profile();\n $profile->account_fields = 'Field1, Field2, Field3';\n $serviceMock->profile = $profile;\n\n $serviceMock->expects($this->once())\n ->method('convertCrmData')\n ->willReturn(['test']);\n\n $this->app->bind(QueryBuilder::class, function () {\n $queryBuilder = $this->createMock(QueryBuilder::class);\n $queryBuilder->expects($this->once())\n ->method('buildMatchByDomainQuery')\n ->with('test_email@testserver.com')\n ->willReturn('FIND {test_email@testserver.com} IN ALL FIELDS RETURNING Account(Id)');\n\n return $queryBuilder;\n });\n\n $team = $this->createMock(Team::class);\n\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n\n $data = $serviceMock->matchByDomain('test_email@testserver.com');\n\n $this->assertEquals(['test'], $data);\n }\n\n public function testBuildTaskSearchFields(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)\n );\n\n $fields = $service->buildTaskSearchFields();\n\n $expectedFields = ['Id', 'WhoId', 'WhatId', 'AccountId'];\n\n $this->assertEquals($expectedFields, $fields);\n }\n\n public function testMapCrmObjects(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class),\n );\n\n $sampleTask = [\n 'WhoId' => '003sampleWhoId',\n 'AccountId' => 'sampleAccountId',\n 'WhatId' => 'sampleWhatId',\n ];\n\n $activityData = $service->mapCrmObjects($sampleTask);\n\n $expectedActivityData = [\n 'contact' => '003sampleWhoId',\n 'account' => 'sampleAccountId',\n 'opportunity' => 'sampleWhatId',\n ];\n\n $this->assertEquals($expectedActivityData, $activityData);\n }\n\n public function testGetInstalledAppVersion(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n $queryIterator = $this->createMock(QueryIterator::class);\n $queryIterator->expects($this->any())\n ->method('current')->willReturn([\n 'SubscriberPackageVersion' => [\n 'MajorVersion' => '1',\n 'MinorVersion' => '0',\n 'PatchVersion' => '1',\n 'BuildNumber' => '0',\n ],\n ]);\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('metadata')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods(array_diff(get_class_methods(Service::class), ['getInstalledAppVersion']))\n ->getMock();\n\n $version = $serviceMock->getInstalledAppVersion();\n\n $this->assertEquals('1010', $version);\n }\n\n public function testSyncProfiles(): void\n {\n $userToSearch = null;\n\n $team = $this->createMock(Team::class);\n $config = $this->createMockedConfiguration();\n $config->expects($this->once())\n ->method('getId')\n ->willReturn(1);\n\n $salesforceUser = [\n 'Email' => 'test@example.com',\n 'UserPreferencesLightningExperiencePreferred' => true,\n 'CallCenterId' => '123',\n 'Id' => '456',\n 'ProfileId' => '789',\n ];\n\n app()->bind(QueryBuilder::class, function () use ($userToSearch) {\n $queryBuilder = $this->createMock(QueryBuilder::class);\n $queryBuilder->expects($this->once())\n ->method('buildGetUsersQuery')\n ->with($userToSearch)\n ->willReturn('SELECT * FROM Users');\n\n return $queryBuilder;\n });\n\n $queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('query')\n ->with('SELECT * FROM Users')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n\n $user = $this->createMock(User::class);\n $user->expects($this->once())\n ->method('getId')\n ->willReturn(123);\n\n $this->mockTeamRepository($team, $salesforceUser, $user);\n\n $profileRepository = $this->createMock(ProfileRepository::class);\n $profileRepository->expects($this->once())\n ->method('updateOrCreateProfile')\n ->with(\n $user,\n [\n 'crm_configuration_id' => 1,\n 'crm_provider_id' => '456',\n ],\n [\n 'user_id' => 123,\n 'edition' => Profile::EDITION_LIGHTNING,\n 'has_external_cti' => true,\n 'crm_profile_id' => '789',\n ]\n )\n ->willReturn(new Profile());\n\n $this->app->instance(ProfileRepository::class, $profileRepository);\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n $result = $serviceMock->syncProfiles($userToSearch);\n\n $this->assertNull($result);\n }\n\n public function testSyncProfilesEmailIsNull(): void\n {\n $userToSearch = $this->createMock(User::class);\n\n $salesforceUser = [\n 'Email' => null,\n ];\n\n app()->bind(QueryBuilder::class, function () {\n $queryBuilder = $this->createMock(QueryBuilder::class);\n $queryBuilder->expects($this->once())\n ->method('buildGetUsersQuery')\n ->with(null)\n ->willReturn('SELECT * FROM Users');\n\n return $queryBuilder;\n });\n\n $queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->exactly(2))\n ->method('query')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n\n $team = $this->createMock(Team::class);\n $user = $this->createMock(User::class);\n $this->mockTeamRepository($team, $salesforceUser, $user, false);\n\n $config = $this->createMock(Configuration::class);\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n\n $profile = $serviceMock->syncProfiles(null);\n\n $this->assertNull($profile);\n }\n\n public function testSyncProfilesUserToSearchMatchesCurrentUser(): void\n {\n $userToSearch = $this->createMock(User::class);\n $userToSearch->expects($this->once())\n ->method('getId')\n ->willReturn(123);\n\n $team = $this->createMock(Team::class);\n $config = $this->createMock(Configuration::class);\n $config->expects($this->once())\n ->method('getId')\n ->willReturn(1);\n\n $salesforceUser = [\n 'Email' => 'test@example.com',\n 'UserPreferencesLightningExperiencePreferred' => true,\n 'CallCenterId' => '123',\n 'Id' => '456',\n 'ProfileId' => '789',\n ];\n\n app()->bind(QueryBuilder::class, function () use ($userToSearch) {\n $queryBuilder = $this->createMock(QueryBuilder::class);\n $queryBuilder->expects($this->once())\n ->method('buildGetUsersQuery')\n ->with($userToSearch)\n ->willReturn('SELECT * FROM Users');\n\n return $queryBuilder;\n });\n\n $queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('query')\n ->with('SELECT * FROM Users')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n\n $user = $this->createMock(User::class);\n $user->expects($this->exactly(2))\n ->method('getId')\n ->willReturn(123);\n\n $this->mockTeamRepository($team, $salesforceUser, $user);\n\n $profileRepository = $this->createMock(ProfileRepository::class);\n $profileRepository->expects($this->once())\n ->method('updateOrCreateProfile')\n ->with(\n $user,\n [\n 'crm_configuration_id' => 1,\n 'crm_provider_id' => '456',\n ],\n [\n 'user_id' => 123,\n 'edition' => Profile::EDITION_LIGHTNING,\n 'has_external_cti' => true,\n 'crm_profile_id' => '789',\n ]\n )\n ->willReturn(new Profile());\n\n $this->app->instance(ProfileRepository::class, $profileRepository);\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n $profile = $serviceMock->syncProfiles($userToSearch);\n\n $this->assertInstanceOf(Profile::class, $profile);\n }\n\n public function testSyncProfilesWithCustomValidation(): void\n {\n $userToSearch = null;\n\n $team = $this->createMock(Team::class);\n $config = $this->createMockedConfiguration();\n $config->expects($this->atLeastOnce()) // Changed from once() to atLeastOnce()\n ->method('getId')\n ->willReturn(1);\n\n $salesforceUser = [\n 'Email' => 'test@example.com',\n 'UserPreferencesLightningExperiencePreferred' => true,\n 'CallCenterId' => '123',\n 'Id' => '456',\n 'ProfileId' => '789',\n 'CustomField' => 'CustomValue',\n ];\n\n $customRules = [\n ['field' => 'CustomField', 'value' => 'CustomValue'],\n ];\n\n $this->mockQueryBuilderAndHandler($userToSearch, [$salesforceUser]);\n\n $user = $this->createMock(User::class);\n $user->expects($this->once())\n ->method('getId')\n ->willReturn(123);\n\n $this->mockTeamRepository($team, $salesforceUser, $user, true, $customRules);\n\n $this->mockProfileRepository($user);\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n $profile = $serviceMock->syncProfiles($userToSearch);\n\n $this->assertNull($profile);\n }\n\n public function testSyncProfilesWithCustomValidationFailing(): void\n {\n $userToSearch = null;\n\n $team = $this->createMock(Team::class);\n $config = $this->createMockedConfiguration();\n\n $salesforceUser = [\n 'Email' => 'test@example.com',\n 'UserPreferencesLightningExperiencePreferred' => true,\n 'CallCenterId' => '123',\n 'Id' => '456',\n 'ProfileId' => '789',\n 'CustomField' => 'WrongValue',\n ];\n\n $customRules = [\n ['field' => 'CustomField', 'value' => 'CustomValue'],\n ];\n\n $this->mockQueryBuilderAndHandler($userToSearch, [$salesforceUser]);\n\n $teamRepository = $this->getMockForAbstractClass(TeamRepository::class, [], '', false, true, true, ['findActiveTeamMemberByEmail', 'getTeamSetting']);\n\n $teamSettings = $this->createMock(TeamSettings::class);\n $teamSettings->method('getValueType')\n ->willReturn('array');\n\n $teamSettings->method('getValue')\n ->willReturn(json_encode($customRules));\n\n $teamRepository->expects($this->once())\n ->method('getTeamSetting')\n ->with($team, 'custom_profile_validation')\n ->willReturn($teamSettings);\n\n app()->bind(TeamRepository::class, function () use ($teamRepository) {\n return $teamRepository;\n });\n\n $profileRepository = $this->createMock(ProfileRepository::class);\n $profileRepository->expects($this->never())\n ->method('updateOrCreateProfile');\n\n $this->app->instance(ProfileRepository::class, $profileRepository);\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n $result = $serviceMock->syncProfiles($userToSearch);\n\n $this->assertNull($result);\n }\n\n public function testGetContactRolesFromCrm(): void\n {\n $contactRoles = [\n [\n 'Id' => '1',\n 'ContactId' => 'Contact1',\n 'OpportunityId' => 'Opportunity1',\n 'Opportunity' => ['OwnerId' => 'Owner1'],\n 'IsPrimary' => true,\n 'Role' => 'Decision Maker',\n ],\n ];\n\n $expectedResponse = [\n [\n 'id' => '1',\n 'contactId' => 'Contact1',\n 'opportunityId' => 'Opportunity1',\n 'ownerId' => 'Owner1',\n 'isPrimary' => true,\n 'role' => 'Decision Maker',\n ],\n ];\n\n $this->bindQueryIterator($contactRoles);\n\n $serviceMock = $this->getServiceMock();\n\n $data = $serviceMock->getContactRolesFromCrm(now()->subDay());\n\n $this->assertEquals($expectedResponse, $data);\n }\n\n public function testGetContactRolesFromCrmNoResult(): void\n {\n $this->bindQueryIterator([]);\n\n $serviceMock = $this->getServiceMock();\n\n $data = $serviceMock->getContactRolesFromCrm(now()->subDay());\n\n $this->assertEquals([], $data);\n }\n\n public function testSyncContactRoles(): void\n {\n $contactRoles = [\n [\n 'id' => '1',\n 'contactId' => 'Contact1',\n 'opportunityId' => 'Opportunity1',\n 'ownerId' => 'Owner1',\n 'isPrimary' => true,\n 'role' => 'Decision Maker',\n ],\n ];\n\n app()->bind(ContactRoleRepository::class, function () {\n $contactRoleRepository = $this->createMock(ContactRoleRepository::class);\n $contactRoleRepository->expects($this->once())\n ->method('saveContactRoles');\n\n return $contactRoleRepository;\n });\n\n $serviceMock = $this->getServiceMock([\n 'getContactRolesFromCrm',\n 'syncRemotelyDeletedContactRoles',\n 'syncContact',\n 'syncOpportunity',\n ]);\n\n $config = $this->createMock(Configuration::class);\n $hasMany = $this->createMock(HasManyExtended::class);\n $hasMany->expects($this->exactly(2))\n ->method('where')\n ->willReturn($hasMany);\n\n $hasMany->expects($this->exactly(2))\n ->method('first')\n ->willReturn(\n $this->createMock(Contact::class),\n $this->createMock(Opportunity::class)\n );\n\n $config->expects($this->once())\n ->method('contacts')\n ->willReturn($hasMany);\n $config->expects($this->once())\n ->method('opportunities')\n ->willReturn($hasMany);\n\n $serviceMock->config = $config;\n\n $serviceMock->expects($this->once())\n ->method('getContactRolesFromCrm')\n ->willReturn($contactRoles);\n\n $serviceMock->expects($this->once())\n ->method('syncRemotelyDeletedContactRoles');\n\n $data = $serviceMock->syncContactRoles(now()->subDay());\n\n $this->assertEquals(1, $data);\n }\n\n public function testSyncRemotelyDeletedContactRoles(): void\n {\n $contactRoles = [\n [\n 'id' => '1',\n 'crm_provider_id' => '1',\n ],\n ];\n\n app()->bind(QueryHandler::class, function () use ($contactRoles) {\n $queryResults = new QueryResults($contactRoles, 1, true, null);\n\n $handler = $this->createMock(QueryHandler::class);\n $handler->method('queryDeleted')\n ->willReturn($queryResults);\n\n return $handler;\n });\n\n app()->bind(ContactRoleRepository::class, function () {\n $contactRoleRepository = $this->createMock(ContactRoleRepository::class);\n $contactRoleRepository->expects($this->once())\n ->method('deleteContactRoles');\n\n return $contactRoleRepository;\n });\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $this->createMock(Team::class);\n\n $data = $this->invokePrivateMethod('syncRemotelyDeletedContactRoles', $serviceMock, []);\n\n $this->assertTrue($data);\n }\n\n private function bindQueryIterator(array $queryResult): void\n {\n /** @var Client $client */\n $client = $this->createMock(Client::class);\n $queryIterator = new QueryIterator(\n $client,\n new QueryResults($queryResult, 1, true, null)\n );\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('query')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n }\n\n public static function getOpportunitySortOrderDataProvider(): array\n {\n return [\n 'all open recently updated' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED, ['LastModifiedDate DESC', true]],\n 'all open recently created' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED, ['CreatedDate DESC', true]],\n 'all open oldest created' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED, ['CreatedDate ASC', true]],\n 'all recently updated' => [Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED, ['LastModifiedDate DESC', false]],\n 'default' => ['unknown', ['LastModifiedDate DESC', true]],\n ];\n }\n\n private function createMockedConfiguration(): Configuration\n {\n $config = $this->createMock(Configuration::class);\n $profilesRelation = $this->getMockBuilder(\\Illuminate\\Database\\Eloquent\\Relations\\HasMany::class)\n ->disableOriginalConstructor()\n ->onlyMethods(['get'])\n ->addMethods(['where', 'first'])\n ->getMock();\n $profilesRelation->method('where')->willReturnSelf();\n $profilesRelation->method('get')->willReturn(collect([]));\n $profilesRelation->method('first')->willReturn(null);\n $config->method('profiles')->willReturn($profilesRelation);\n\n return $config;\n }\n\n private function getServiceMock(array $onlyMethods = []): MockObject&Service\n {\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $this->createMock(Client::class),\n $this->createMock(PayloadBuilder::class),\n $this->createMock(Dispatcher::class),\n $this->createMock(CountriesMap::class),\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods($onlyMethods)\n ->getMock();\n\n $serviceMock->profile = $this->createMock(Profile::class);\n\n return $serviceMock;\n }\n\n private function mockQueryBuilderAndHandler($userToSearch, $salesforceUsers): void\n {\n app()->bind(QueryBuilder::class, function () use ($userToSearch) {\n $queryBuilder = $this->createMock(QueryBuilder::class);\n $queryBuilder->expects($this->once())\n ->method('buildGetUsersQuery')\n ->with($userToSearch)\n ->willReturn('SELECT * FROM Users');\n\n return $queryBuilder;\n });\n\n $queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults($salesforceUsers, count($salesforceUsers), true, null));\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('query')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n }\n\n private function mockTeamRepository(\n Team $team,\n array $salesforceUser,\n ?User $user = null,\n bool $userSearch = true,\n array $customRules = []\n ): void {\n $teamRepository = $this->getMockForAbstractClass(TeamRepository::class, [], '', false, true, true, ['findActiveTeamMemberByEmail', 'getTeamSetting']);\n\n if ($userSearch) {\n $teamRepository->expects($this->once())\n ->method('findActiveTeamMemberByEmail')\n ->with($team, $salesforceUser['Email'])\n ->willReturn($user);\n }\n\n $teamSettings = $this->createMock(TeamSettings::class);\n $teamSettings->method('getValueType')\n ->willReturn('array');\n\n $teamSettings->method('getValue')\n ->willReturn(json_encode($customRules));\n\n $teamRepository->expects($this->once())\n ->method('getTeamSetting')\n ->with($team, 'custom_profile_validation')\n ->willReturn($teamSettings);\n\n app()->bind(TeamRepository::class, function () use ($teamRepository) {\n return $teamRepository;\n });\n }\n\n private function mockProfileRepository(User $user): void\n {\n $profileRepository = $this->createMock(ProfileRepository::class);\n $profileRepository->expects($this->once())\n ->method('updateOrCreateProfile')\n ->with(\n $user,\n [\n 'crm_configuration_id' => 1,\n 'crm_provider_id' => '456',\n ],\n [\n 'user_id' => 123,\n 'edition' => Profile::EDITION_LIGHTNING,\n 'has_external_cti' => true,\n 'crm_profile_id' => '789',\n ]\n )\n ->willReturn(new Profile());\n\n $this->app->instance(ProfileRepository::class, $profileRepository);\n }\n\n public function testBuildEnhancedNoteDecodesHtmlEntities(): void\n {\n $service = $this->getServiceMock(['createRecord']);\n\n $profile = new Profile();\n $profile->setAttribute('crm_provider_id', 'owner-123');\n $service->profile = $profile;\n\n $service->expects($this->exactly(2))\n ->method('createRecord')\n ->willReturnOnConsecutiveCalls('note-id-123', 'link-id-456');\n\n $bodyWithEntities = 'Welch's current challenges and Facebook's Club';\n\n $result = $this->invokePrivateMethod('buildEnhancedNote', $service, [\n 'Test Title',\n $bodyWithEntities,\n 'object-id-789',\n ]);\n\n $this->assertEquals('note-id-123', $result);\n }\n\n public function testBuildEnhancedNoteSanitizesWithoutQuotes(): void\n {\n $service = $this->getServiceMock(['createRecord']);\n\n $profile = new Profile();\n $profile->setAttribute('crm_provider_id', 'owner-456');\n $service->profile = $profile;\n\n $service->expects($this->exactly(2))\n ->method('createRecord')\n ->willReturnCallback(function ($type, $data) {\n if ($type === 'ContentNote') {\n $decoded = base64_decode($data['Content']);\n $this->assertStringContainsString(\"Welch's\", $decoded);\n $this->assertStringNotContainsString(''', $decoded);\n $this->assertStringNotContainsString('&#039;', $decoded);\n $this->assertStringContainsString('<script>', $decoded);\n\n return 'note-id-456';\n }\n\n return 'link-id-789';\n });\n\n $bodyWithMixedContent = \"Welch's and <script>alert('xss')</script>\";\n\n $result = $this->invokePrivateMethod('buildEnhancedNote', $service, [\n 'Test Title',\n $bodyWithMixedContent,\n 'object-id-123',\n ]);\n\n $this->assertEquals('note-id-456', $result);\n }\n\n public function testBuildEnhancedNoteConvertsLineBreaks(): void\n {\n $service = $this->getServiceMock(['createRecord']);\n\n $profile = new Profile();\n $profile->setAttribute('crm_provider_id', 'owner-789');\n $service->profile = $profile;\n\n $service->expects($this->exactly(2))\n ->method('createRecord')\n ->willReturnCallback(function ($type, $data) {\n if ($type === 'ContentNote') {\n $decoded = base64_decode($data['Content']);\n $this->assertStringContainsString('<br>', $decoded);\n $this->assertStringNotContainsString('<br />', $decoded);\n\n return 'note-id-789';\n }\n\n return 'link-id-012';\n });\n\n $bodyWithLineBreaks = \"Line 1\\nLine 2\\nLine 3\";\n\n $result = $this->invokePrivateMethod('buildEnhancedNote', $service, [\n 'Test Title',\n $bodyWithLineBreaks,\n 'object-id-456',\n ]);\n\n $this->assertEquals('note-id-789', $result);\n }\n\n public function testBuildEnhancedNoteHandlesComplexScenario(): void\n {\n $service = $this->getServiceMock(['createRecord']);\n\n $profile = new Profile();\n $profile->setAttribute('crm_provider_id', 'owner-complex');\n $service->profile = $profile;\n\n $service->expects($this->exactly(2))\n ->method('createRecord')\n ->willReturnCallback(function ($type, $data) {\n if ($type === 'ContentNote') {\n $decoded = base64_decode($data['Content']);\n\n $this->assertStringContainsString(\"Welch's\", $decoded);\n $this->assertStringContainsString(\"Facebook's Club\", $decoded);\n $this->assertStringContainsString(\"Arctics'\", $decoded);\n $this->assertStringNotContainsString(''', $decoded);\n $this->assertStringNotContainsString('&#039;', $decoded);\n $this->assertStringContainsString('<br>', $decoded);\n $this->assertStringContainsString('<', $decoded);\n $this->assertStringContainsString('>', $decoded);\n\n return 'note-complex';\n }\n\n return 'link-complex';\n });\n\n $complexBody = \"Summary:\\n---------\\nThe call focused on understanding Welch's current challenges and exploring how Arctics' Revenue Growth Management solutions could support their strategic goals.\\n\\n• John SMith discussed his role as a category advisor for Google and Facebook's Club, emphasizing the importance of market research and advising on product assortment.\\n• Madona introduced Arctics' Virtual Shoppers AI, which simulates consumer <behavior> to optimize pricing and promotional strategies.\";\n\n $result = $this->invokePrivateMethod('buildEnhancedNote', $service, [\n 'Jiminny Transcription Summary',\n $complexBody,\n 'task-id-001',\n ]);\n\n $this->assertEquals('note-complex', $result);\n }\n\n public function testSyncRemotelyDeletedObjectsWithErrorHandlingSuccess(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['syncRemotelyDeletedObjects'])\n ->getMock();\n\n // Mock team\n $team = $this->createMock(Team::class);\n $team->method('getUuid')->willReturn('team-uuid-123');\n $serviceMock->team = $team;\n\n // Expect syncRemotelyDeletedObjects to be called once and succeed\n $serviceMock->expects($this->once())\n ->method('syncRemotelyDeletedObjects')\n ->with(\\Jiminny\\Enums\\CrmObject::ACCOUNT);\n\n // Call the protected method using reflection\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');\n $method->setAccessible(true);\n\n // Should not throw any exceptions\n $method->invoke($serviceMock, \\Jiminny\\Enums\\CrmObject::ACCOUNT);\n\n $this->assertTrue(true); // Test completed successfully\n }\n\n public function testSyncRemotelyDeletedObjectsWithErrorHandlingFailure(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['syncRemotelyDeletedObjects'])\n ->getMock();\n\n // Mock team\n $team = $this->createMock(Team::class);\n $team->method('getUuid')->willReturn('team-uuid-456');\n $serviceMock->team = $team;\n\n // Mock logger to verify warning is logged\n $logger = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n\n // Use reflection to set the protected logger property\n $reflection = new \\ReflectionClass($serviceMock);\n $loggerProperty = $reflection->getProperty('logger');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($serviceMock, $logger);\n\n $exception = new \\Exception('Sync failed due to API error');\n\n // Expect syncRemotelyDeletedObjects to throw an exception\n $serviceMock->expects($this->once())\n ->method('syncRemotelyDeletedObjects')\n ->with(\\Jiminny\\Enums\\CrmObject::CONTACT)\n ->willThrowException($exception);\n\n // Expect warning to be logged with correct message and parameters\n $logger->expects($this->once())\n ->method('warning')\n ->with(\n '[Salesforce] Remotely deleted objects sync failed',\n [\n 'objectType' => 'contact',\n 'teamId' => 'team-uuid-456',\n 'reason' => 'Sync failed due to API error',\n ]\n );\n\n // Call the protected method using reflection\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');\n $method->setAccessible(true);\n\n // Should not re-throw the exception, just log it\n $method->invoke($serviceMock, \\Jiminny\\Enums\\CrmObject::CONTACT);\n\n $this->assertTrue(true); // Test completed successfully\n }\n\n public function testSyncRemotelyDeletedObjectsWithErrorHandlingWithLogParams(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['syncRemotelyDeletedObjects'])\n ->getMock();\n\n // Mock team\n $team = $this->createMock(Team::class);\n $team->method('getUuid')->willReturn('team-uuid-789');\n $serviceMock->team = $team;\n\n // Mock logger to verify warning is logged\n $logger = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n\n // Use reflection to set the protected logger property\n $loggerReflection = new \\ReflectionClass($serviceMock);\n $loggerProperty = $loggerReflection->getProperty('logger');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($serviceMock, $logger);\n\n $exception = new \\Exception('Network timeout');\n\n // Expect syncRemotelyDeletedObjects to throw an exception\n $serviceMock->expects($this->once())\n ->method('syncRemotelyDeletedObjects')\n ->with(\\Jiminny\\Enums\\CrmObject::OPPORTUNITY)\n ->willThrowException($exception);\n\n // Additional log parameters\n $logParams = [\n 'syncType' => 'full',\n 'batchSize' => 100,\n ];\n\n // Expect warning to be logged with merged parameters\n $logger->expects($this->once())\n ->method('warning')\n ->with(\n '[Salesforce] Remotely deleted objects sync failed',\n [\n 'objectType' => 'opportunity',\n 'teamId' => 'team-uuid-789',\n 'reason' => 'Network timeout',\n 'syncType' => 'full',\n 'batchSize' => 100,\n ]\n );\n\n // Call the protected method using reflection\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');\n $method->setAccessible(true);\n\n // Should not re-throw the exception, just log it\n $method->invoke($serviceMock, \\Jiminny\\Enums\\CrmObject::OPPORTUNITY, $logParams);\n\n $this->assertTrue(true); // Test completed successfully\n }\n\n /**\n * @dataProvider crmObjectProvider\n */\n public function testSyncRemotelyDeletedObjectsWithErrorHandlingDifferentCrmObjects(\\Jiminny\\Enums\\CrmObject $crmObject): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['syncRemotelyDeletedObjects'])\n ->getMock();\n\n // Mock team\n $team = $this->createMock(Team::class);\n $team->method('getUuid')->willReturn('team-uuid-test');\n $serviceMock->team = $team;\n\n // Mock logger to verify warning is logged\n $logger = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n\n // Use reflection to set the protected logger property\n $loggerReflectionClass = new \\ReflectionClass($serviceMock);\n $loggerProperty = $loggerReflectionClass->getProperty('logger');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($serviceMock, $logger);\n\n $exception = new \\Exception('Test error');\n\n // Expect syncRemotelyDeletedObjects to throw an exception\n $serviceMock->expects($this->once())\n ->method('syncRemotelyDeletedObjects')\n ->with($crmObject)\n ->willThrowException($exception);\n\n // Expect warning to be logged with correct entity type\n $logger->expects($this->once())\n ->method('warning')\n ->with(\n '[Salesforce] Remotely deleted objects sync failed',\n [\n 'objectType' => $crmObject->value,\n 'teamId' => 'team-uuid-test',\n 'reason' => 'Test error',\n ]\n );\n\n // Call the protected method using reflection\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');\n $method->setAccessible(true);\n\n $method->invoke($serviceMock, $crmObject);\n\n $this->assertTrue(true); // Test completed successfully\n }\n\n public function testHandleObjectDeletionWithDeletedEntity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['deleteCrmObject'])\n ->getMock();\n\n $entity = $this->createMock(\\Jiminny\\Models\\Account::class);\n $crmData = ['IsDeleted' => true];\n\n $serviceMock->expects($this->once())\n ->method('deleteCrmObject')\n ->with($entity);\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('handleObjectDeletion');\n $method->setAccessible(true);\n\n $method->invoke($serviceMock, $entity, $crmData);\n }\n\n public function testHandleObjectDeletionWithNonDeletedEntity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['deleteCrmObject'])\n ->getMock();\n\n $entity = $this->createMock(\\Jiminny\\Models\\Contact::class);\n $crmData = ['IsDeleted' => false];\n\n $serviceMock->expects($this->never())\n ->method('deleteCrmObject');\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('handleObjectDeletion');\n $method->setAccessible(true);\n\n $method->invoke($serviceMock, $entity, $crmData);\n }\n\n public function testDeleteCrmObjectWithValidEntity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['dispatchDeleteCrmObjectJob'])\n ->getMock();\n\n $entity = $this->createMock(\\Jiminny\\Models\\Lead::class);\n $entity->expects($this->once())->method('delete');\n\n $serviceMock->expects($this->once())\n ->method('dispatchDeleteCrmObjectJob')\n ->with($entity);\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('deleteCrmObject');\n $method->setAccessible(true);\n\n $method->invoke($serviceMock, $entity);\n }\n\n public function testDeleteCrmObjectWithNullEntity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['dispatchDeleteCrmObjectJob'])\n ->getMock();\n\n $serviceMock->expects($this->never())\n ->method('dispatchDeleteCrmObjectJob');\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('deleteCrmObject');\n $method->setAccessible(true);\n\n $method->invoke($serviceMock, null);\n }\n\n public function testDispatchDeleteCrmObjectJobWithNullEntity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($service);\n $method = $reflection->getMethod('dispatchDeleteCrmObjectJob');\n $method->setAccessible(true);\n\n // Should return early without dispatching - no exception expected\n $method->invoke($service, null);\n\n $this->assertTrue(true); // Test completed successfully\n }\n\n public function testDispatchDeleteCrmObjectJobWithUnsupportedEntity(): void\n {\n $this->expectException(\\TypeError::class);\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n $unsupportedEntity = $this->createMock(\\stdClass::class);\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($service);\n $method = $reflection->getMethod('dispatchDeleteCrmObjectJob');\n\n // This will throw TypeError due to union type constraint\n $method->invoke($service, $unsupportedEntity);\n }\n\n public function testHandleEntityDeletionByProviderIdMethodExists(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n // Test that the method exists and is accessible via reflection\n $reflection = new \\ReflectionClass($service);\n $method = $reflection->getMethod('handleEntityDeletionByProviderId');\n $method->setAccessible(true);\n\n // Verify method exists and has correct parameters\n $this->assertTrue($method->isProtected());\n $this->assertEquals(2, $method->getNumberOfParameters());\n\n $parameters = $method->getParameters();\n $this->assertEquals('targetEntity', $parameters[0]->getName());\n $this->assertEquals('crmData', $parameters[1]->getName());\n }\n\n public function testSyncRemotelyDeletedObjectsWithNoResults(): void\n {\n // Create a real service instance to avoid mock property issues\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n // Mock queryHandler to throw NoResultsException\n $queryHandler = $this->createMock(QueryHandler::class);\n $queryHandler->expects($this->once())\n ->method('queryDeleted')\n ->with('Opportunity')\n ->willThrowException(new NoResultsException('No results'));\n\n // Set the queryHandler using reflection on the real service\n $reflection = new \\ReflectionClass($service);\n $queryHandlerProperty = $reflection->getProperty('queryHandler');\n $queryHandlerProperty->setAccessible(true);\n $queryHandlerProperty->setValue($service, $queryHandler);\n\n $result = self::invokePrivateMethod('syncRemotelyDeletedObjects', $service, [CrmObject::OPPORTUNITY]);\n\n $this->assertFalse($result);\n }\n\n public function testSyncRemotelyDeletedObjectsWithEmptyResults(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n // Mock queryHandler to return empty results\n $queryResult = $this->createMock(QueryResults::class);\n $queryResult->method('getResults')->willReturn([]);\n\n $queryHandler = $this->createMock(QueryHandler::class);\n $queryHandler->expects($this->once())\n ->method('queryDeleted')\n ->with('Opportunity')\n ->willReturn($queryResult);\n\n // Set the queryHandler using reflection on the real service\n $reflection = new \\ReflectionClass($service);\n $queryHandlerProperty = $reflection->getProperty('queryHandler');\n $queryHandlerProperty->setAccessible(true);\n $queryHandlerProperty->setValue($service, $queryHandler);\n\n $result = self::invokePrivateMethod('syncRemotelyDeletedObjects', $service, [CrmObject::OPPORTUNITY]);\n\n $this->assertFalse($result);\n }\n\n public function testSyncRemotelyDeletedObjectsWithUnsupportedCrmObject(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n // Mock queryHandler to return some deleted objects so we reach the match statement\n $deletedObjects = [\n ['id' => 'task1'],\n ['id' => 'task2'],\n ];\n $queryResult = $this->createMock(QueryResults::class);\n $queryResult->method('getResults')->willReturn($deletedObjects);\n\n $queryHandler = $this->createMock(QueryHandler::class);\n $queryHandler->expects($this->once())\n ->method('queryDeleted')\n ->with('Task') // ucfirst('task') = 'Task'\n ->willReturn($queryResult);\n\n self::setPrivateProperty($service, 'queryHandler', $queryHandler);\n\n $this->expectException(InvalidArgumentException::class);\n $this->expectExceptionMessage('Unsupported CrmObject: task');\n\n self::invokePrivateMethod('syncRemotelyDeletedObjects', $service, [CrmObject::TASK]);\n }\n\n public static function crmObjectProvider(): array\n {\n return [\n 'Account' => [CrmObject::ACCOUNT],\n 'Contact' => [CrmObject::CONTACT],\n 'Lead' => [CrmObject::LEAD],\n 'Opportunity' => [CrmObject::OPPORTUNITY],\n ];\n }\n\n public function testVerifyTaskExistsReturnsTrue(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-123', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-123');\n $activity->method('getId')->willReturn(456);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-123', ['Id', 'IsDeleted'])\n ->willReturn(['Id' => 'task-123', 'IsDeleted' => false]);\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertTrue($result);\n }\n\n public function testVerifyTaskExistsReturnsTrueForEvent(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:event-123', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('event-123');\n $activity->method('getId')->willReturn(456);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_EVENT);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Event', 'event-123', ['Id', 'IsDeleted'])\n ->willReturn(['Id' => 'event-123', 'IsDeleted' => false]);\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertTrue($result);\n }\n\n public function testVerifyTaskExistsReturnsFalseWhenDeleted(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-456', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-456');\n $activity->method('getId')->willReturn(789);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-456', ['Id', 'IsDeleted'])\n ->willReturn(['Id' => 'task-456', 'IsDeleted' => true]);\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertFalse($result);\n }\n\n public function testVerifyTaskExistsReturnsFalseWhenNotFound(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-999', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-999');\n $activity->method('getId')->willReturn(999);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-999', ['Id', 'IsDeleted'])\n ->willThrowException(new \\Jiminny\\Exceptions\\HttpNotFoundException('Task not found'));\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertFalse($result);\n }\n\n public function testVerifyTaskExistsReturnsFalseWhenNoPlaybook(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-no-playbook', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-no-playbook');\n $activity->method('getId')->willReturn(111);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn(null);\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertFalse($result);\n }\n\n public function testVerifyTaskExistsThrowsExceptionForTransientErrors(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-error', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-error');\n $activity->method('getId')->willReturn(888);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-error', ['Id', 'IsDeleted'])\n ->willThrowException(new \\RuntimeException('Network timeout'));\n\n $this->expectException(\\RuntimeException::class);\n $this->expectExceptionMessage('Network timeout');\n\n $service->verifyTaskExists($activity);\n }\n\n public function testVerifyTaskExistsCachesResults(): void\n {\n $cachedValue = null;\n Cache::shouldReceive('remember')\n ->twice()\n ->with('crm_task_exists:123:task-cached', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(function ($key, $ttl, $callback) use (&$cachedValue) {\n if ($cachedValue === null) {\n $cachedValue = $callback();\n }\n\n return $cachedValue;\n });\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-cached');\n $activity->method('getId')->willReturn(555);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-cached', ['Id', 'IsDeleted'])\n ->willReturn(['Id' => 'task-cached', 'IsDeleted' => false]);\n\n $result1 = $service->verifyTaskExists($activity);\n $result2 = $service->verifyTaskExists($activity);\n\n $this->assertTrue($result1);\n $this->assertTrue($result2);\n }\n\n public function testVerifyTaskExistsReturnsFalseForHttpBadRequestException(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-400', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-400');\n $activity->method('getId')->willReturn(400);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-400', ['Id', 'IsDeleted'])\n ->willThrowException(new \\Jiminny\\Exceptions\\HttpBadRequestException('Bad request'));\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertFalse($result);\n }\n\n public function testImportOpportunitySkipsWhenNoProfileAndNoAccount(): void\n {\n $crmData = [\n 'Id' => 'SF-NO-USER-1',\n 'Name' => 'Test Opportunity',\n 'OwnerId' => 'owner-no-profile',\n // No AccountId\n ];\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(\\Illuminate\\Events\\Dispatcher::class); // ← ADD THIS\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n\n $service = new Service(\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService\n );\n\n $config = $this->createMock(Configuration::class);\n\n // Mock profiles relation returning null (no profile found)\n $profilesRelation = \\Mockery::mock(\\Illuminate\\Database\\Eloquent\\Relations\\HasMany::class);\n $profilesRelation->shouldReceive('where')->with('crm_provider_id', 'owner-no-profile')->andReturnSelf();\n $profilesRelation->shouldReceive('first')->andReturn(null);\n\n $config->expects($this->once())\n ->method('profiles')\n ->willReturn($profilesRelation);\n\n $team = $this->createMock(Team::class);\n $team->method('getId')->willReturn(1);\n\n $logger = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n $logger->expects($this->once())\n ->method('error')\n ->with(\n '[Salesforce] | Skip import, no user_id found',\n ['id' => 'SF-NO-USER-1']\n );\n\n $reflection = new \\ReflectionClass($service);\n\n $configProperty = $reflection->getProperty('config');\n $configProperty->setAccessible(true);\n $configProperty->setValue($service, $config);\n\n $teamProperty = $reflection->getProperty('team');\n $teamProperty->setAccessible(true);\n $teamProperty->setValue($service, $team);\n\n $loggerProperty = $reflection->getProperty('logger');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($service, $logger);\n\n // Initialize profile property to avoid \"must not be accessed before initialization\" error\n $profileProperty = $reflection->getProperty('profile');\n $profileProperty->setAccessible(true);\n $profileProperty->setValue($service, null);\n\n $result = self::invokePrivateMethod('importOpportunity', $service, [$crmData]);\n\n $this->assertNull($result);\n }\n\n public function testImportOpportunitySkipsWhenProfileNotFound(): void\n {\n $crmData = [\n 'Id' => 'SF-NO-USER-2',\n 'Name' => 'Test Opportunity',\n 'OwnerId' => 'owner-not-found',\n // No AccountId - avoid complex account processing\n ];\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $eventDispatcher = $this->createMock(\\Illuminate\\Events\\Dispatcher::class); // ← ADD THIS\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n\n\n $service = new Service(\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService\n );\n\n $config = $this->createMock(Configuration::class);\n\n // Mock profiles relation returning null (no profile found)\n $profilesRelation = \\Mockery::mock(\\Illuminate\\Database\\Eloquent\\Relations\\HasMany::class);\n $profilesRelation->shouldReceive('where')->with('crm_provider_id', 'owner-not-found')->andReturnSelf();\n $profilesRelation->shouldReceive('first')->andReturn(null);\n\n $config->expects($this->once())\n ->method('profiles')\n ->willReturn($profilesRelation);\n\n $team = $this->createMock(Team::class);\n $team->method('getId')->willReturn(1);\n\n $logger = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n $logger->expects($this->once())\n ->method('error')\n ->with(\n '[Salesforce] | Skip import, no user_id found',\n ['id' => 'SF-NO-USER-2']\n );\n\n $reflection = new \\ReflectionClass($service);\n\n $configProperty = $reflection->getProperty('config');\n $configProperty->setAccessible(true);\n $configProperty->setValue($service, $config);\n\n $teamProperty = $reflection->getProperty('team');\n $teamProperty->setAccessible(true);\n $teamProperty->setValue($service, $team);\n\n $loggerProperty = $reflection->getProperty('logger');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($service, $logger);\n\n // Initialize profile property\n $profileProperty = $reflection->getProperty('profile');\n $profileProperty->setAccessible(true);\n $profileProperty->setValue($service, null);\n\n $result = self::invokePrivateMethod('importOpportunity', $service, [$crmData]);\n\n $this->assertNull($result);\n }\n}\n\nclass HasManyExtended extends HasMany\n{\n public function where()\n {\n }\n\n public function first()\n {\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false}]...
|
-219575626138687605
|
-6520523286572803291
|
click
|
accessibility
|
NULL
|
Project: faVsco.js, menu
JY-20963-fix-import-on-de Project: faVsco.js, menu
JY-20963-fix-import-on-deleted-entity, menu
Start Listening for PHP Debug Connections
ServiceTest
Run 'ServiceTest'
Debug 'ServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Built-in Preview
Chrome
Firefox
Safari
Sync Changes
Hide This Notification
Code changed:
Hide
4
32
168
1
28
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Tests\Unit\Services\Crm\Salesforce;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Events\Dispatcher;
use Illuminate\Support\Facades\Cache;
use Jiminny\Component\Country\CountriesMap;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Enums\CrmObject;
use Jiminny\Exceptions\InvalidArgumentException;
use Jiminny\Exceptions\NoResultsException;
use Jiminny\Integrations\PlaybookResolver;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Playbook;
use Jiminny\Models\Team;
use Jiminny\Models\TeamSettings;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\ContactRoleRepository;
use Jiminny\Repositories\Crm\FieldDataRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\Salesforce\Client;
use Jiminny\Services\Crm\Salesforce\PayloadBuilder;
use Jiminny\Services\Crm\Salesforce\QueryBuilder;
use Jiminny\Services\Crm\Salesforce\QueryHandler;
use Jiminny\Services\Crm\Salesforce\QueryIterator;
use Jiminny\Services\Crm\Salesforce\QueryResults;
use Jiminny\Services\Crm\Salesforce\Service;
use PHPUnit\Framework\MockObject\MockObject;
use Tests\TestCase;
use Tests\Unit\Traits\TestPrivateMethod;
class ServiceTest extends TestCase
{
use TestPrivateMethod;
public function testFetchAndAssociateRelatedActivity(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$payloadBuilder->method('addCustomLogicFieldsPayload')
->willReturnCallback(function ($activity, $payload) {
return $payload;
});
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods(['fetchRelatedActivity', 'getPlaybook', 'getPlaybookCategory', 'updateRecord'])
->getMock();
$serviceMock->expects($this->once())
->method('fetchRelatedActivity')
->willReturn([
'Id' => 'testId',
'Type' => null,
'OwnerId' => 'testerUser',
'Description' => 'Test description',
]);
$user = $this->createMock(User::class);
$team = $this->createMock(Team::class);
$user->method('getAttribute')->with('team')->willReturn($team);
$playbook = $this->createMock(Playbook::class);
$playbook->method('getActivityField')->willReturn(null);
$playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_EVENT);
$serviceMock->expects($this->once())
->method('getPlaybook')
->with($user)
->willReturn($playbook);
$serviceMock->expects($this->never())
->method('getPlaybookCategory');
$serviceMock->expects($this->never())
->method('updateRecord');
$fieldDataRepository = $this->createMock(FieldDataRepository::class);
$fieldDataRepository->method('getActivityFieldData')->willReturn(collect([]));
app()->instance(FieldDataRepository::class, $fieldDataRepository);
$config = $this->createMock(Configuration::class);
$profilesRelation = $this->getMockBuilder(\Illuminate\Database\Eloquent\Relations\HasMany::class)
->disableOriginalConstructor()
->onlyMethods(['get'])
->addMethods(['where'])
->getMock();
$profilesRelation->method('where')->willReturnSelf();
$profilesRelation->method('get')->willReturn(collect([]));
$config->method('profiles')->willReturn($profilesRelation);
$serviceMock->config = $config;
$serviceMock->profile = null;
$actualStartTime = \Carbon\Carbon::now();
$activity = $this->getMockBuilder(Activity::class)
->disableOriginalConstructor()
->onlyMethods(['update', 'hasProspect'])
->getMock();
$activity->method('update')->willReturn(true);
$activity->method('hasProspect')->willReturn(true);
$activity->type = Activity::TYPE_CONFERENCE;
$activity->provider = Activity::PROVIDER_TWILIO;
$activity->lead_id = 1;
$activity->user_id = 0;
$activity->id_string = 'test-activity-id';
$activity->user = $user;
$activity->actual_start_time = $actualStartTime;
$activity->uuid = 'c53d8320-f556-4cee-a2f8-5f232f454ca4';
app()->bind(PlaybookResolver::class, function () use ($user) {
$playbook = $this->createMock(Playbook::class);
$playbookResolver = $this->createMock(PlaybookResolver::class);
$playbookResolver->expects($this->once())
->method('resolvePlaybookByUser')
->with($user)
->willReturn($playbook);
return $playbookResolver;
});
$data = $serviceMock->fetchAndAssociateRelatedActivity($activity);
$this->assertInstanceOf(Activity::class, $data);
$this->assertEquals(Activity::TYPE_CONFERENCE, $data->getType());
$this->assertEquals($actualStartTime->getTimestamp(), $data->getActualStartTime()->getTimestamp());
}
public function testFetchAndAssociateRelatedActivitySkipsForTaskBasedPlaybook(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods(['fetchRelatedActivity', 'getPlaybook'])
->getMock();
$user = $this->createMock(User::class);
$playbook = $this->createMock(Playbook::class);
$playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);
$playbook->method('getId')->willReturn(123);
$serviceMock->expects($this->once())
->method('getPlaybook')
->with($user)
->willReturn($playbook);
$serviceMock->expects($this->never())
->method('fetchRelatedActivity');
$activity = $this->getMockBuilder(Activity::class)
->disableOriginalConstructor()
->onlyMethods(['hasProspect', 'getUuid'])
->getMock();
$activity->method('hasProspect')->willReturn(true);
$activity->method('getUuid')->willReturn('c53d8320-f556-4cee-a2f8-5f232f454ca4');
$activity->type = Activity::TYPE_CONFERENCE;
$activity->actual_start_time = \Carbon\Carbon::now();
$activity->user = $user;
$result = $serviceMock->fetchAndAssociateRelatedActivity($activity);
$this->assertNull($result);
}
public function testFetchAndAssociateRelatedActivityReturnsNullForNonConference(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$serviceMock = new Service(
client: $client,
payloadBuilder: $payloadBuilder,
eventDispatcher: $eventDispatcher,
countriesMap: $countriesMap,
prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)
);
$activity = $this->getMockBuilder(Activity::class)
->disableOriginalConstructor()
->getMock();
$activity->type = Activity::TYPE_SOFTPHONE;
$result = $serviceMock->fetchAndAssociateRelatedActivity($activity);
$this->assertNull($result);
}
public function testFetchAndAssociateRelatedActivityReturnsNullWhenNoStartTime(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$serviceMock = new Service(
client: $client,
payloadBuilder: $payloadBuilder,
eventDispatcher: $eventDispatcher,
countriesMap: $countriesMap,
prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)
);
$activity = $this->getMockBuilder(Activity::class)
->disableOriginalConstructor()
->getMock();
$activity->type = Activity::TYPE_CONFERENCE;
$activity->actual_start_time = null;
$activity->scheduled_start_time = null;
$result = $serviceMock->fetchAndAssociateRelatedActivity($activity);
$this->assertNull($result);
}
public function testFetchAndAssociateRelatedActivityReturnsNullWhenNoProspect(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods(['getPlaybook'])
->getMock();
$serviceMock->expects($this->never())
->method('getPlaybook');
$activity = $this->getMockBuilder(Activity::class)
->disableOriginalConstructor()
->onlyMethods(['hasProspect', 'getUuid'])
->getMock();
$activity->method('hasProspect')->willReturn(false);
$activity->method('getUuid')->willReturn('c53d8320-f556-4cee-a2f8-5f232f454ca4');
$activity->type = Activity::TYPE_CONFERENCE;
$activity->actual_start_time = \Carbon\Carbon::now();
$result = $serviceMock->fetchAndAssociateRelatedActivity($activity);
$this->assertNull($result);
}
public function testMatchExactlyByEmail(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods([])
->getMock();
$profile = new Profile();
$profile->setAttribute('id', bin2hex(random_bytes(8)));
$serviceMock->profile = $profile;
$team = $this->createMock(Team::class);
$serviceMock->team = $team;
$data = $serviceMock->matchExactlyByEmail(bin2hex(random_bytes(8)) . '[EMAIL]');
$this->assertEquals(null, $data);
}
public function testMatchDomainFromEmail(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$queryIterator = $this->createMock(QueryIterator::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$config = $this->createMock(Configuration::class);
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('search')
->willReturn($queryIterator);
return $handler;
});
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods(['convertCrmData'])
->getMock();
$profile = new Profile();
$profile->account_fields = 'Field1, Field2, Field3';
$serviceMock->profile = $profile;
$serviceMock->expects($this->once())
->method('convertCrmData')
->willReturn(['test']);
$this->app->bind(QueryBuilder::class, function () {
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->expects($this->once())
->method('buildMatchByDomainQuery')
->with('[EMAIL]')
->willReturn('FIND {[EMAIL]} IN ALL FIELDS RETURNING Account(Id)');
return $queryBuilder;
});
$team = $this->createMock(Team::class);
$serviceMock->team = $team;
$serviceMock->config = $config;
$data = $serviceMock->matchByDomain('[EMAIL]');
$this->assertEquals(['test'], $data);
}
public function testBuildTaskSearchFields(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$service = new Service(
client: $client,
payloadBuilder: $payloadBuilder,
eventDispatcher: $eventDispatcher,
countriesMap: $countriesMap,
prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)
);
$fields = $service->buildTaskSearchFields();
$expectedFields = ['Id', 'WhoId', 'WhatId', 'AccountId'];
$this->assertEquals($expectedFields, $fields);
}
public function testMapCrmObjects(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$service = new Service(
client: $client,
payloadBuilder: $payloadBuilder,
eventDispatcher: $eventDispatcher,
countriesMap: $countriesMap,
prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class),
);
$sampleTask = [
'WhoId' => '003sampleWhoId',
'AccountId' => 'sampleAccountId',
'WhatId' => 'sampleWhatId',
];
$activityData = $service->mapCrmObjects($sampleTask);
$expectedActivityData = [
'contact' => '003sampleWhoId',
'account' => 'sampleAccountId',
'opportunity' => 'sampleWhatId',
];
$this->assertEquals($expectedActivityData, $activityData);
}
public function testGetInstalledAppVersion(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$queryIterator = $this->createMock(QueryIterator::class);
$queryIterator->expects($this->any())
->method('current')->willReturn([
'SubscriberPackageVersion' => [
'MajorVersion' => '1',
'MinorVersion' => '0',
'PatchVersion' => '1',
'BuildNumber' => '0',
],
]);
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('metadata')
->willReturn($queryIterator);
return $handler;
});
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods(array_diff(get_class_methods(Service::class), ['getInstalledAppVersion']))
->getMock();
$version = $serviceMock->getInstalledAppVersion();
$this->assertEquals('1010', $version);
}
public function testSyncProfiles(): void
{
$userToSearch = null;
$team = $this->createMock(Team::class);
$config = $this->createMockedConfiguration();
$config->expects($this->once())
->method('getId')
->willReturn(1);
$salesforceUser = [
'Email' => '[EMAIL]',
'UserPreferencesLightningExperiencePreferred' => true,
'CallCenterId' => '123',
'Id' => '456',
'ProfileId' => '789',
];
app()->bind(QueryBuilder::class, function () use ($userToSearch) {
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->expects($this->once())
->method('buildGetUsersQuery')
->with($userToSearch)
->willReturn('SELECT * FROM Users');
return $queryBuilder;
});
$queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('query')
->with('SELECT * FROM Users')
->willReturn($queryIterator);
return $handler;
});
$user = $this->createMock(User::class);
$user->expects($this->once())
->method('getId')
->willReturn(123);
$this->mockTeamRepository($team, $salesforceUser, $user);
$profileRepository = $this->createMock(ProfileRepository::class);
$profileRepository->expects($this->once())
->method('updateOrCreateProfile')
->with(
$user,
[
'crm_configuration_id' => 1,
'crm_provider_id' => '456',
],
[
'user_id' => 123,
'edition' => Profile::EDITION_LIGHTNING,
'has_external_cti' => true,
'crm_profile_id' => '789',
]
)
->willReturn(new Profile());
$this->app->instance(ProfileRepository::class, $profileRepository);
$serviceMock = $this->getServiceMock();
$serviceMock->team = $team;
$serviceMock->config = $config;
$result = $serviceMock->syncProfiles($userToSearch);
$this->assertNull($result);
}
public function testSyncProfilesEmailIsNull(): void
{
$userToSearch = $this->createMock(User::class);
$salesforceUser = [
'Email' => null,
];
app()->bind(QueryBuilder::class, function () {
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->expects($this->once())
->method('buildGetUsersQuery')
->with(null)
->willReturn('SELECT * FROM Users');
return $queryBuilder;
});
$queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->exactly(2))
->method('query')
->willReturn($queryIterator);
return $handler;
});
$team = $this->createMock(Team::class);
$user = $this->createMock(User::class);
$this->mockTeamRepository($team, $salesforceUser, $user, false);
$config = $this->createMock(Configuration::class);
$serviceMock = $this->getServiceMock();
$serviceMock->team = $team;
$serviceMock->config = $config;
$profile = $serviceMock->syncProfiles(null);
$this->assertNull($profile);
}
public function testSyncProfilesUserToSearchMatchesCurrentUser(): void
{
$userToSearch = $this->createMock(User::class);
$userToSearch->expects($this->once())
->method('getId')
->willReturn(123);
$team = $this->createMock(Team::class);
$config = $this->createMock(Configuration::class);
$config->expects($this->once())
->method('getId')
->willReturn(1);
$salesforceUser = [
'Email' => '[EMAIL]',
'UserPreferencesLightningExperiencePreferred' => true,
'CallCenterId' => '123',
'Id' => '456',
'ProfileId' => '789',
];
app()->bind(QueryBuilder::class, function () use ($userToSearch) {
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->expects($this->once())
->method('buildGetUsersQuery')
->with($userToSearch)
->willReturn('SELECT * FROM Users');
return $queryBuilder;
});
$queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('query')
->with('SELECT * FROM Users')
->willReturn($queryIterator);
return $handler;
});
$user = $this->createMock(User::class);
$user->expects($this->exactly(2))
->method('getId')
->willReturn(123);
$this->mockTeamRepository($team, $salesforceUser, $user);
$profileRepository = $this->createMock(ProfileRepository::class);
$profileRepository->expects($this->once())
->method('updateOrCreateProfile')
->with(
$user,
[
'crm_configuration_id' => 1,
'crm_provider_id' => '456',
],
[
'user_id' => 123,
'edition' => Profile::EDITION_LIGHTNING,
'has_external_cti' => true,
'crm_profile_id' => '789',
]
)
->willReturn(new Profile());
$this->app->instance(ProfileRepository::class, $profileRepository);
$serviceMock = $this->getServiceMock();
$serviceMock->team = $team;
$serviceMock->config = $config;
$profile = $serviceMock->syncProfiles($userToSearch);
$this->assertInstanceOf(Profile::class, $profile);
}
public function testSyncProfilesWithCustomValidation(): void
{
$userToSearch = null;
$team = $this->createMock(Team::class);
$config = $this->createMockedConfiguration();
$config->expects($this->atLeastOnce()) // Changed from once() to atLeastOnce()
->method('getId')
->willReturn(1);
$salesforceUser = [
'Email' => '[EMAIL]',
'UserPreferencesLightningExperiencePreferred' => true,
'CallCenterId' => '123',
'Id' => '456',
'ProfileId' => '789',
'CustomField' => 'CustomValue',
];
$customRules = [
['field' => 'CustomField', 'value' => 'CustomValue'],
];
$this->mockQueryBuilderAndHandler($userToSearch, [$salesforceUser]);
$user = $this->createMock(User::class);
$user->expects($this->once())
->method('getId')
->willReturn(123);
$this->mockTeamRepository($team, $salesforceUser, $user, true, $customRules);
$this->mockProfileRepository($user);
$serviceMock = $this->getServiceMock();
$serviceMock->team = $team;
$serviceMock->config = $config;
$profile = $serviceMock->syncProfiles($userToSearch);
$this->assertNull($profile);
}
public function testSyncProfilesWithCustomValidationFailing(): void
{
$userToSearch = null;
$team = $this->createMock(Team::class);
$config = $this->createMockedConfiguration();
$salesforceUser = [
'Email' => '[EMAIL]',
'UserPreferencesLightningExperiencePreferred' => true,
'CallCenterId' => '123',
'Id' => '456',
'ProfileId' => '789',
'CustomField' => 'WrongValue',
];
$customRules = [
['field' => 'CustomField', 'value' => 'CustomValue'],
];
$this->mockQueryBuilderAndHandler($userToSearch, [$salesforceUser]);
$teamRepository = $this->getMockForAbstractClass(TeamRepository::class, [], '', false, true, true, ['findActiveTeamMemberByEmail', 'getTeamSetting']);
$teamSettings = $this->createMock(TeamSettings::class);
$teamSettings->method('getValueType')
->willReturn('array');
$teamSettings->method('getValue')
->willReturn(json_encode($customRules));
$teamRepository->expects($this->once())
->method('getTeamSetting')
->with($team, 'custom_profile_validation')
->willReturn($teamSettings);
app()->bind(TeamRepository::class, function () use ($teamRepository) {
return $teamRepository;
});
$profileRepository = $this->createMock(ProfileRepository::class);
$profileRepository->expects($this->never())
->method('updateOrCreateProfile');
$this->app->instance(ProfileRepository::class, $profileRepository);
$serviceMock = $this->getServiceMock();
$serviceMock->team = $team;
$serviceMock->config = $config;
$result = $serviceMock->syncProfiles($userToSearch);
$this->assertNull($result);
}
public function testGetContactRolesFromCrm(): void
{
$contactRoles = [
[
'Id' => '1',
'ContactId' => 'Contact1',
'OpportunityId' => 'Opportunity1',
'Opportunity' => ['OwnerId' => 'Owner1'],
'IsPrimary' => true,
'Role' => 'Decision Maker',
],
];
$expectedResponse = [
[
'id' => '1',
'contactId' => 'Contact1',
'opportunityId' => 'Opportunity1',
'ownerId' => 'Owner1',
'isPrimary' => true,
'role' => 'Decision Maker',
],
];
$this->bindQueryIterator($contactRoles);
$serviceMock = $this->getServiceMock();
$data = $serviceMock->getContactRolesFromCrm(now()->subDay());
$this->assertEquals($expectedResponse, $data);
}
public function testGetContactRolesFromCrmNoResult(): void
{
$this->bindQueryIterator([]);
$serviceMock = $this->getServiceMock();
$data = $serviceMock->getContactRolesFromCrm(now()->subDay());
$this->assertEquals([], $data);
}
public function testSyncContactRoles(): void
{
$contactRoles = [
[
'id' => '1',
'contactId' => 'Contact1',
'opportunityId' => 'Opportunity1',
'ownerId' => 'Owner1',
'isPrimary' => true,
'role' => 'Decision Maker',
],
];
app()->bind(ContactRoleRepository::class, function () {
$contactRoleRepository = $this->createMock(ContactRoleRepository::class);
$contactRoleRepository->expects($this->once())
->method('saveContactRoles');
return $contactRoleRepository;
});
$serviceMock = $this->getServiceMock([
'getContactRolesFromCrm',
'syncRemotelyDeletedContactRoles',
'syncContact',
'syncOpportunity',
]);
$config = $this->createMock(Configuration::class);
$hasMany = $this->createMock(HasManyExtended::class);
$hasMany->expects($this->exactly(2))
->method('where')
->willReturn($hasMany);
$hasMany->expects($this->exactly(2))
->method('first')
->willReturn(
$this->createMock(Contact::class),
$this->createMock(Opportunity::class)
);
$config->expects($this->once())
->method('contacts')
->willReturn($hasMany);
$config->expects($this->once())
->method('opportunities')
->willReturn($hasMany);
$serviceMock->config = $config;
$serviceMock->expects($this->once())
->method('getContactRolesFromCrm')
->willReturn($contactRoles);
$serviceMock->expects($this->once())
->method('syncRemotelyDeletedContactRoles');
$data = $serviceMock->syncContactRoles(now()->subDay());
$this->assertEquals(1, $data);
}
public function testSyncRemotelyDeletedContactRoles(): void
{
$contactRoles = [
[
'id' => '1',
'crm_provider_id' => '1',
],
];
app()->bind(QueryHandler::class, function () use ($contactRoles) {
$queryResults = new QueryResults($contactRoles, 1, true, null);
$handler = $this->createMock(QueryHandler::class);
$handler->method('queryDeleted')
->willReturn($queryResults);
return $handler;
});
app()->bind(ContactRoleRepository::class, function () {
$contactRoleRepository = $this->createMock(ContactRoleRepository::class);
$contactRoleRepository->expects($this->once())
->method('deleteContactRoles');
return $contactRoleRepository;
});
$serviceMock = $this->getServiceMock();
$serviceMock->team = $this->createMock(Team::class);
$data = $this->invokePrivateMethod('syncRemotelyDeletedContactRoles', $serviceMock, []);
$this->assertTrue($data);
}
private function bindQueryIterator(array $queryResult): void
{
/** @var Client $client */
$client = $this->createMock(Client::class);
$queryIterator = new QueryIterator(
$client,
new QueryResults($queryResult, 1, true, null)
);
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('query')
->willReturn($queryIterator);
return $handler;
});
}
public static function getOpportunitySortOrderDataProvider(): array
{
return [
'all open recently updated' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED, ['LastModifiedDate DESC', true]],
'all open recently created' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED, ['CreatedDate DESC', true]],
'all open oldest created' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED, ['CreatedDate ASC', true]],
'all recently updated' => [Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED, ['LastModifiedDate DESC', false]],
'default' => ['unknown', ['LastModifiedDate DESC', true]],
];
}
private function createMockedConfiguration(): Configuration
{
$config = $this->createMock(Configuration::class);
$profilesRelation = $this->getMockBuilder(\Illuminate\Database\Eloquent\Relations\HasMany::class)
->disableOriginalConstructor()
->onlyMethods(['get'])
->addMethods(['where', 'first'])
->getMock();
$profilesRelation->method('where')->willReturnSelf();
$profilesRelation->method('get')->willReturn(collect([]));
$profilesRelation->method('first')->willReturn(null);
$config->method('profiles')->willReturn($profilesRelation);
return $config;
}
private function getServiceMock(array $onlyMethods = []): MockObject&Service
{
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$this->createMock(Client::class),
$this->createMock(PayloadBuilder::class),
$this->createMock(Dispatcher::class),
$this->createMock(CountriesMap::class),
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods($onlyMethods)
->getMock();
$serviceMock->profile = $this->createMock(Profile::class);
return $serviceMock;
}
private function mockQueryBuilderAndHandler($userToSearch, $salesforceUsers): void
{
app()->bind(QueryBuilder::class, function () use ($userToSearch) {
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->expects($this->once())
->method('buildGetUsersQuery')
->with($userToSearch)
->willReturn('SELECT * FROM Users');
return $queryBuilder;
});
$queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults($salesforceUsers, count($salesforceUsers), true, null));
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('query')
->willReturn($queryIterator);
return $handler;
});
}
private function mockTeamRepository(
Team $team,
array $salesforceUser,
?User $user = null,
bool $userSearch = true,
array $customRules = []
): void {
$teamRepository = $this->getMockForAbstractClass(TeamRepository::class, [], '', false, true, true, ['findActiveTeamMemberByEmail', 'getTeamSetting']);
if ($userSearch) {
$teamRepository->expects($this->once())
->method('findActiveTeamMemberByEmail')
->with($team, $salesforceUser['Email'])
->willReturn($user);
}
$teamSettings = $this->createMock(TeamSettings::class);
$teamSettings->method('getValueType')
->willReturn('array');
$teamSettings->method('getValue')
->willReturn(json_encode($customRules));
$teamRepository->expects($this->once())
->method('getTeamSetting')
->with($team, 'custom_profile_validation')
->willReturn($teamSettings);
app()->bind(TeamRepository::class, function () use ($teamRepository) {
return $teamRepository;
});
}
private function mockProfileRepository(User $user): void
{
$profileRepository = $this->createMock(ProfileRepository::class);
$profileRepository->expects($this->once())
->method('updateOrCreateProfile')
->with(
$user,
[
'crm_configuration_id' => 1,
'crm_provider_id' => '456',
],
[
'user_id' => 123,
'edition' => Profile::EDITION_LIGHTNING,
'has_external_cti' => true,
'crm_profile_id' => '789',
]
)
->willReturn(new Profile());
$this->app->instance(ProfileRepository::class, $profileRepository);
}
public function testBuildEnhancedNoteDecodesHtmlEntities(): void
{
$service = $this->getServiceMock(['createRecord']);
$profile = new Profile();
$profile->setAttribute('crm_provider_id', 'owner-123');
$service->profile = $profile;
$service->expects($this->exactly(2))
->method('createRecord')
->willReturnOnConsecutiveCalls('note-id-123', 'link-id-456');
$bodyWithEntities = 'Welch's current challenges and Facebook's Club';
$result = $this->invokePrivateMethod('buildEnhancedNote', $service, [
'Test Title',
$bodyWithEntities,
'object-id-789',
]);
$this->assertEquals('note-id-123', $result);
}
public function testBuildEnhancedNoteSanitizesWithoutQuotes(): void
{
$service = $this->getServiceMock(['createRecord']);
$profile = new Profile();
$profile->setAttribute('crm_provider_id', 'owner-456');
$service->profile = $profile;
$service->expects($this->exactly(2))
->method('createRecord')
->willReturnCallback(function ($type, $data) {
if ($type === 'ContentNote') {
$decoded = base64_decode($data['Content']);
$this->assertStringContainsString("Welch's", $decoded);
$this->assertStringNotContainsString(''', $decoded);
$this->assertStringNotContainsString('&#039;', $decoded);
$this->assertStringContainsString('<script>', $decoded);
return 'note-id-456';
}
return 'link-id-789';
});
$bodyWithMixedContent = "Welch's and <script>alert('xss')</script>";
$result = $this->invokePrivateMethod('buildEnhancedNote', $service, [
'Test Title',
$bodyWithMixedContent,
'object-id-123',
]);
$this->assertEquals('note-id-456', $result);
}
public function testBuildEnhancedNoteConvertsLineBreaks(): void
{
$service = $this->getServiceMock(['createRecord']);
$profile = new Profile();
$profile->setAttribute('crm_provider_id', 'owner-789');
$service->profile = $profile;
$service->expects($this->exactly(2))
->method('createRecord')
->willReturnCallback(function ($type, $data) {
if ($type === 'ContentNote') {
$decoded = base64_decode($data['Content']);
$this->assertStringContainsString('<br>', $decoded);
$this->assertStringNotContainsString('<br />', $decoded);
return 'note-id-789';
}
return 'link-id-012';
});
$bodyWithLineBreaks = "Line 1\nLine 2\nLine 3";
$result = $this->invokePrivateMethod('buildEnhancedNote', $service, [
'Test Title',
$bodyWithLineBreaks,
'object-id-456',
]);
$this->assertEquals('note-id-789', $result);
}
public function testBuildEnhancedNoteHandlesComplexScenario(): void
{
$service = $this->getServiceMock(['createRecord']);
$profile = new Profile();
$profile->setAttribute('crm_provider_id', 'owner-complex');
$service->profile = $profile;
$service->expects($this->exactly(2))
->method('createRecord')
->willReturnCallback(function ($type, $data) {
if ($type === 'ContentNote') {
$decoded = base64_decode($data['Content']);
$this->assertStringContainsString("Welch's", $decoded);
$this->assertStringContainsString("Facebook's Club", $decoded);
$this->assertStringContainsString("Arctics'", $decoded);
$this->assertStringNotContainsString(''', $decoded);
$this->assertStringNotContainsString('&#039;', $decoded);
$this->assertStringContainsString('<br>', $decoded);
$this->assertStringContainsString('<', $decoded);
$this->assertStringContainsString('>', $decoded);
return 'note-complex';
}
return 'link-complex';
});
$complexBody = "Summary:\n---------\nThe call focused on understanding Welch's current challenges and exploring how Arctics' Revenue Growth Management solutions could support their strategic goals.\n\n• John SMith discussed his role as a category advisor for Google and Facebook's Club, emphasizing the importance of market research and advising on product assortment.\n• Madona introduced Arctics' Virtual Shoppers AI, which simulates consumer <behavior> to optimize pricing and promotional strategies.";
$result = $this->invokePrivateMethod('buildEnhancedNote', $service, [
'Jiminny Transcription Summary',
$complexBody,
'task-id-001',
]);
$this->assertEquals('note-complex', $result);
}
public function testSyncRemotelyDeletedObjectsWithErrorHandlingSuccess(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$prospectPhotoPathService,
])
->onlyMethods(['syncRemotelyDeletedObjects'])
->getMock();
// Mock team
$team = $this->createMock(Team::class);
$team->method('getUuid')->willReturn('team-uuid-123');
$serviceMock->team = $team;
// Expect syncRemotelyDeletedObjects to be called once and succeed
$serviceMock->expects($this->once())
->method('syncRemotelyDeletedObjects')
->with(\Jiminny\Enums\CrmObject::ACCOUNT);
// Call the protected method using reflection
$reflection = new \ReflectionClass($serviceMock);
$method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');
$method->setAccessible(true);
// Should not throw any exceptions
$method->invoke($serviceMock, \Jiminny\Enums\CrmObject::ACCOUNT);
$this->assertTrue(true); // Test completed successfully
}
public function testSyncRemotelyDeletedObjectsWithErrorHandlingFailure(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$prospectPhotoPathService,
])
->onlyMethods(['syncRemotelyDeletedObjects'])
->getMock();
// Mock team
$team = $this->createMock(Team::class);
$team->method('getUuid')->willReturn('team-uuid-456');
$serviceMock->team = $team;
// Mock logger to verify warning is logged
$logger = $this->createMock(\Psr\Log\LoggerInterface::class);
// Use reflection to set the protected logger property
$reflection = new \ReflectionClass($serviceMock);
$loggerProperty = $reflection->getProperty('logger');
$loggerProperty->setAccessible(true);
$loggerProperty->setValue($serviceMock, $logger);
$exception = new \Exception('Sync failed due to API error');
// Expect syncRemotelyDeletedObjects to throw an exception
$serviceMock->expects($this->once())
->method('syncRemotelyDeletedObjects')
->with(\Jiminny\Enums\CrmObject::CONTACT)
->willThrowException($exception);
// Expect warning to be logged with correct message and parameters
$logger->expects($this->once())
->method('warning')
->with(
'[Salesforce] Remotely deleted objects sync failed',
[
'objectType' => 'contact',
'teamId' => 'team-uuid-456',
'reason' => 'Sync failed due to API error',
]
);
// Call the protected method using reflection
$reflection = new \ReflectionClass($serviceMock);
$method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');
$method->setAccessible(true);
// Should not re-throw the exception, just log it
$method->invoke($serviceMock, \Jiminny\Enums\CrmObject::CONTACT);
$this->assertTrue(true); // Test completed successfully
}
public function testSyncRemotelyDeletedObjectsWithErrorHandlingWithLogParams(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$prospectPhotoPathService,
])
->onlyMethods(['syncRemotelyDeletedObjects'])
->getMock();
// Mock team
$team = $this->createMock(Team::class);
$team->method('getUuid')->willReturn('team-uuid-789');
$serviceMock->team = $team;
// Mock logger to verify warning is logged
$logger = $this->createMock(\Psr\Log\LoggerInterface::class);
// Use reflection to set the protected logger property
$loggerReflection = new \ReflectionClass($serviceMock);
$loggerProperty = $loggerReflection->getProperty('logger');
$loggerProperty->setAccessible(true);
$loggerProperty->setValue($serviceMock, $logger);
$exception = new \Exception('Network timeout');
// Expect syncRemotelyDeletedObjects to throw an exception
$serviceMock->expects($this->once())
->method('syncRemotelyDeletedObjects')
->with(\Jiminny\Enums\CrmObject::OPPORTUNITY)
->willThrowException($exception);
// Additional log parameters
$logParams = [
'syncType' => 'full',
'batchSize' => 100,
];
// Expect warning to be logged with merged parameters
$logger->expects($this->once())
->method('warning')
->with(
'[Salesforce] Remotely deleted objects sync failed',
[
'objectType' => 'opportunity',
'teamId' => 'team-uuid-789',
'reason' => 'Network timeout',
'syncType' => 'full',
'batchSize' => 100,
]
);
// Call the protected method using reflection
$reflection = new \ReflectionClass($serviceMock);
$method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');
$method->setAccessible(true);...
|
70633
|
NULL
|
NULL
|
NULL
|
|
70635
|
2537
|
10
|
2026-05-22T10:26:05.758388+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779445565758_m2.jpg...
|
PhpStorm
|
faVsco.js – ServiceTest.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20963-fix-import-on-de Project: faVsco.js, menu
JY-20963-fix-import-on-deleted-entity, menu...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.025930852,"top":0.019952115,"width":0.03856383,"height":0.025538707},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JY-20963-fix-import-on-deleted-entity, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.09607713,"height":0.025538707},"on_screen":true,"help_text":"Git Branch: JY-20963-fix-import-on-deleted-entity","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
6723900260935776497
|
-8554428926477755212
|
visual_change
|
hybrid
|
NULL
|
Project: faVsco.js, menu
JY-20963-fix-import-on-de Project: faVsco.js, menu
JY-20963-fix-import-on-deleted-entity, menu
PhostormVIewINavicarecodeFV faVsco.js?9 JY-20963-fix-import-on-deleted-entityProiect• m Zoom• MZ00mBotcoomrnoneC ActivitvCrmfieldsResolverlest.ongeActivitvProviderclientlest.onp©) ActivityProviderRegistryTest.php©) ActivityProviderServiceTest.php© CallDenormalizerRegistryTest.php© CrmOwnerResolverTest.php© MeetingBotServiceOnMeetingStartedi) MeeuingbotserviceonsharingDatales© MeetingBotServiceRequestRecordingic) PanicipantConsentservicelest.onoe) Panicioanisservicelest.onoc) ResponseValidationTraitTest.onoc) TrackRecordingFileSizeServiceTest.orc) TrackRecordingSizeEntorcerTest.onoc)ValidateEmitProspectEventTraitTest.ActivitvProviders•AvatarN Calendan1m Crm• M Bullhorn• Close• D Helpers• D HubspotD IntegrationApp• hlictenerc_ Pipearive_ salestorce•© Fields.@OpportunityMatcher# OpportunitySyncStrateay• ProspectSearchStrateay• 0 ServiceTraits.© ClientTest.php© DecorateActivityTest.phpC DeleteObiectsTraitTest.ohp@ FieldDefinitionsTest.php@ GetActivitvFieldNameTest.ohn@ PavloadBuilderTest.ohp@ QuervBuilderTest.oho@ QuervHandlerTest.ohnc @uerviteratorTest.oho( QuervPesultsTests nhn(c ServiceTect nhnCh SvncRatchRedicServiceTest nhnC RaceServiceTest nhnonts addlT DeleteObjectsTrait.php© DeleteOpportunityJob.phpyMatchactvitycrmbata.ongOcrmactviyserwice.onp© RecordSelector.phpo Acuivily.onpk?php14432 A168 V1 V.28 Adeclare strict-cypes=l)namespace Tests Unit Services Crm Salestorce:use...class Servicelest extends Testcase150 V5100 V.225 %3254V201 V700d381 %403 %435 ₽478 YS553 y597 ₴675 V717 V)771 Vl815 %877 %use TestPrivatelethod:public function testFetchAndAssociateRelatedActivity: voidt...=public function testFetchAndAssociateRelatedActivitySkipsForTaskBasedPlaybook: voidt...}public function testFetchAndAssociateRelatedActivityReturnsNullForNonConference: voidf...}public function testFetchAndAssociateRelatedActivityReturnsNullWhenNoStartTime: void{...}public function testFetchAndAssociateRelatedActivityReturnsNullWhenNoProspect: void{...}public function testMatchExactlyByEmail: void{...}public function testMatchDomainFromEmail(): void{...}public function testBuildTaskSearchFieldsO: void{...}public function testMapCrm0biects: void{...}public function testGetInstalledAppVersion: void{...}public function testSvncProfiles@: voidf...?oublic function testSvncProfilesEma1uisullo: voidk..."public function testSvncProfileslserToSearchMatchesCurrentUser@: voidf...}lnublie function testSvncProfilesWithCustomValidation(• voidf...}public function testSyncProfilesWithCustomValidationFailing(): voidf...}public function testGetContactRolesFromCrm: void{...}public function testGetContactRolesFromCrmNoResult: void{...}public function testSyncContactRoles(): void{...}public function testSyncRemotelyDeletedContactRoles: void{...}= custom.log= laravel.log4 SF jiminny@localhost]4 HS_local [jiminny@localhost]& console [PROD]& console [EU]tii crm_configurations [EU] X A console [STAGING]1rowv-OU+Y- WHERE id = 320AD idMuuid (UUID)Ilo team 1dIo notifiable user 1d! providerI edition(• instance! is sandboxlu versionШ installed_app_version!" sync_metadatasync obiectsT auto sync activity1 crm provider id— M crm base un1O last_synced_at#leads_synced_atI accounts_synced_atI contacts svnced atШopportunities_synced_atJu contact roles svncedat( over_quota_at1 api disabled_ at1• settingsTx: AutovDDLCSVL8=•URDER BY32046ea38f7-c940-395d-9c79-0329d30e9ab7salesforceenterpriseswesd50.00180100D20000008Mdb0EAChttos:binsolutions.mv.salesforce.2026-03-19 08:19:242026-05-22 09:48:212026-05-22 09:48:312026-05-22 09:48:432026-05-22 09:48:292026-05-22 09:48:40<null>enull)""opportunity sync_per1od":"5m" "oppM opportunity assianment rule :onen-recentlv-uodatedIn onnortunity max value250000000.01!opportunity_max_age5779I opportunity valve field id! trigger assignment rulesstore transcrintI softohone override prospect *In defaul+ curnencvI created at2024-11-26 09:21:15I updated at2026-05-22 09:48:43CascadeInvestigate Foreign KeJira Ticket:100% 2• Fri 22 May 13:26:05U ServiceTest v+O •Give me the lira ticket and the descriptionJY-XXXXX: Fix foreign key violation when matching activities to deleted Salesforce opportunitiesWhen an opportunity is deleted in Salesforce and the local deletion job runs, activities that belonged to that opportunity arematched via the RematchActivity0nCrmObjectDetach listener. During this rematch, the system attempts to re-link thewndleObjectDeletion (because IsDeleted = truel)The deleted opportunity's ID is then used in Activity: :updateActivityCrmData to set activity.opportunity_id,causina a foreian key violation because the opportunity no longer exists in the databaseThe FixAdded checks in importLead. importAccount. import0pportunity. and importContact methods to return null if theentty was deleted oy hand leudiectbeletionuphoschas→nano levo ectvelecion(sopportunaty, scrbataif (Sopportunity->trashed)) {return null:return Sopportunity.This orevents the foreian kev constraint violation bv ensurina deleted entities are not returned from the svnc process• Activities will no longer fail with foreian key violations when rematching after opportunity deletion• The rematch process will aracefullv handle deleted Salesforce records bv returnina null instead of deleted entitv IDs• This affects all Salesforce CRM entity types: Lead, Account, Opportunity, and ContactFiles Chanaed;• app/Services/Crm/Salesforce/Service.oho lines 1087-1089. 1224-1226. 1436-1438. 1604-1606)nl .Ask anvthina (&4-L)« Code SWF-1.6•0 1W Windsurf Team1-1.UTE.8io 4 spaces...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
70632
|
2537
|
9
|
2026-05-22T10:26:04.668754+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779445564668_m2.jpg...
|
PhpStorm
|
faVsco.js – ServiceTest.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20963-fix-import-on-de Project: faVsco.js, menu
JY-20963-fix-import-on-deleted-entity, menu
Start Listening for PHP Debug Connections
ServiceTest
Run 'ServiceTest'
Debug 'ServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Built-in Preview
Chrome
Firefox
Safari
Sync Changes
Hide This Notification
Code changed:
Hide
4
32
168
1
28
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Tests\Unit\Services\Crm\Salesforce;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Events\Dispatcher;
use Illuminate\Support\Facades\Cache;
use Jiminny\Component\Country\CountriesMap;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Enums\CrmObject;
use Jiminny\Exceptions\InvalidArgumentException;
use Jiminny\Exceptions\NoResultsException;
use Jiminny\Integrations\PlaybookResolver;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Playbook;
use Jiminny\Models\Team;
use Jiminny\Models\TeamSettings;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\ContactRoleRepository;
use Jiminny\Repositories\Crm\FieldDataRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\Salesforce\Client;
use Jiminny\Services\Crm\Salesforce\PayloadBuilder;
use Jiminny\Services\Crm\Salesforce\QueryBuilder;
use Jiminny\Services\Crm\Salesforce\QueryHandler;
use Jiminny\Services\Crm\Salesforce\QueryIterator;
use Jiminny\Services\Crm\Salesforce\QueryResults;
use Jiminny\Services\Crm\Salesforce\Service;
use PHPUnit\Framework\MockObject\MockObject;
use Tests\TestCase;
use Tests\Unit\Traits\TestPrivateMethod;
class ServiceTest extends TestCase
{
use TestPrivateMethod;
public function testFetchAndAssociateRelatedActivity(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$payloadBuilder->method('addCustomLogicFieldsPayload')
->willReturnCallback(function ($activity, $payload) {
return $payload;
});
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods(['fetchRelatedActivity', 'getPlaybook', 'getPlaybookCategory', 'updateRecord'])
->getMock();
$serviceMock->expects($this->once())
->method('fetchRelatedActivity')
->willReturn([
'Id' => 'testId',
'Type' => null,
'OwnerId' => 'testerUser',
'Description' => 'Test description',
]);
$user = $this->createMock(User::class);
$team = $this->createMock(Team::class);
$user->method('getAttribute')->with('team')->willReturn($team);
$playbook = $this->createMock(Playbook::class);
$playbook->method('getActivityField')->willReturn(null);
$playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_EVENT);
$serviceMock->expects($this->once())
->method('getPlaybook')
->with($user)
->willReturn($playbook);
$serviceMock->expects($this->never())
->method('getPlaybookCategory');
$serviceMock->expects($this->never())
->method('updateRecord');
$fieldDataRepository = $this->createMock(FieldDataRepository::class);
$fieldDataRepository->method('getActivityFieldData')->willReturn(collect([]));
app()->instance(FieldDataRepository::class, $fieldDataRepository);
$config = $this->createMock(Configuration::class);
$profilesRelation = $this->getMockBuilder(\Illuminate\Database\Eloquent\Relations\HasMany::class)
->disableOriginalConstructor()
->onlyMethods(['get'])
->addMethods(['where'])
->getMock();
$profilesRelation->method('where')->willReturnSelf();
$profilesRelation->method('get')->willReturn(collect([]));
$config->method('profiles')->willReturn($profilesRelation);
$serviceMock->config = $config;
$serviceMock->profile = null;
$actualStartTime = \Carbon\Carbon::now();
$activity = $this->getMockBuilder(Activity::class)
->disableOriginalConstructor()
->onlyMethods(['update', 'hasProspect'])
->getMock();
$activity->method('update')->willReturn(true);
$activity->method('hasProspect')->willReturn(true);
$activity->type = Activity::TYPE_CONFERENCE;
$activity->provider = Activity::PROVIDER_TWILIO;
$activity->lead_id = 1;
$activity->user_id = 0;
$activity->id_string = 'test-activity-id';
$activity->user = $user;
$activity->actual_start_time = $actualStartTime;
$activity->uuid = 'c53d8320-f556-4cee-a2f8-5f232f454ca4';
app()->bind(PlaybookResolver::class, function () use ($user) {
$playbook = $this->createMock(Playbook::class);
$playbookResolver = $this->createMock(PlaybookResolver::class);
$playbookResolver->expects($this->once())
->method('resolvePlaybookByUser')
->with($user)
->willReturn($playbook);
return $playbookResolver;
});
$data = $serviceMock->fetchAndAssociateRelatedActivity($activity);
$this->assertInstanceOf(Activity::class, $data);
$this->assertEquals(Activity::TYPE_CONFERENCE, $data->getType());
$this->assertEquals($actualStartTime->getTimestamp(), $data->getActualStartTime()->getTimestamp());
}
public function testFetchAndAssociateRelatedActivitySkipsForTaskBasedPlaybook(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods(['fetchRelatedActivity', 'getPlaybook'])
->getMock();
$user = $this->createMock(User::class);
$playbook = $this->createMock(Playbook::class);
$playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);
$playbook->method('getId')->willReturn(123);
$serviceMock->expects($this->once())
->method('getPlaybook')
->with($user)
->willReturn($playbook);
$serviceMock->expects($this->never())
->method('fetchRelatedActivity');
$activity = $this->getMockBuilder(Activity::class)
->disableOriginalConstructor()
->onlyMethods(['hasProspect', 'getUuid'])
->getMock();
$activity->method('hasProspect')->willReturn(true);
$activity->method('getUuid')->willReturn('c53d8320-f556-4cee-a2f8-5f232f454ca4');
$activity->type = Activity::TYPE_CONFERENCE;
$activity->actual_start_time = \Carbon\Carbon::now();
$activity->user = $user;
$result = $serviceMock->fetchAndAssociateRelatedActivity($activity);
$this->assertNull($result);
}
public function testFetchAndAssociateRelatedActivityReturnsNullForNonConference(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$serviceMock = new Service(
client: $client,
payloadBuilder: $payloadBuilder,
eventDispatcher: $eventDispatcher,
countriesMap: $countriesMap,
prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)
);
$activity = $this->getMockBuilder(Activity::class)
->disableOriginalConstructor()
->getMock();
$activity->type = Activity::TYPE_SOFTPHONE;
$result = $serviceMock->fetchAndAssociateRelatedActivity($activity);
$this->assertNull($result);
}
public function testFetchAndAssociateRelatedActivityReturnsNullWhenNoStartTime(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$serviceMock = new Service(
client: $client,
payloadBuilder: $payloadBuilder,
eventDispatcher: $eventDispatcher,
countriesMap: $countriesMap,
prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)
);
$activity = $this->getMockBuilder(Activity::class)
->disableOriginalConstructor()
->getMock();
$activity->type = Activity::TYPE_CONFERENCE;
$activity->actual_start_time = null;
$activity->scheduled_start_time = null;
$result = $serviceMock->fetchAndAssociateRelatedActivity($activity);
$this->assertNull($result);
}
public function testFetchAndAssociateRelatedActivityReturnsNullWhenNoProspect(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods(['getPlaybook'])
->getMock();
$serviceMock->expects($this->never())
->method('getPlaybook');
$activity = $this->getMockBuilder(Activity::class)
->disableOriginalConstructor()
->onlyMethods(['hasProspect', 'getUuid'])
->getMock();
$activity->method('hasProspect')->willReturn(false);
$activity->method('getUuid')->willReturn('c53d8320-f556-4cee-a2f8-5f232f454ca4');
$activity->type = Activity::TYPE_CONFERENCE;
$activity->actual_start_time = \Carbon\Carbon::now();
$result = $serviceMock->fetchAndAssociateRelatedActivity($activity);
$this->assertNull($result);
}
public function testMatchExactlyByEmail(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods([])
->getMock();
$profile = new Profile();
$profile->setAttribute('id', bin2hex(random_bytes(8)));
$serviceMock->profile = $profile;
$team = $this->createMock(Team::class);
$serviceMock->team = $team;
$data = $serviceMock->matchExactlyByEmail(bin2hex(random_bytes(8)) . '[EMAIL]');
$this->assertEquals(null, $data);
}
public function testMatchDomainFromEmail(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$queryIterator = $this->createMock(QueryIterator::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$config = $this->createMock(Configuration::class);
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('search')
->willReturn($queryIterator);
return $handler;
});
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods(['convertCrmData'])
->getMock();
$profile = new Profile();
$profile->account_fields = 'Field1, Field2, Field3';
$serviceMock->profile = $profile;
$serviceMock->expects($this->once())
->method('convertCrmData')
->willReturn(['test']);
$this->app->bind(QueryBuilder::class, function () {
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->expects($this->once())
->method('buildMatchByDomainQuery')
->with('[EMAIL]')
->willReturn('FIND {[EMAIL]} IN ALL FIELDS RETURNING Account(Id)');
return $queryBuilder;
});
$team = $this->createMock(Team::class);
$serviceMock->team = $team;
$serviceMock->config = $config;
$data = $serviceMock->matchByDomain('[EMAIL]');
$this->assertEquals(['test'], $data);
}
public function testBuildTaskSearchFields(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$service = new Service(
client: $client,
payloadBuilder: $payloadBuilder,
eventDispatcher: $eventDispatcher,
countriesMap: $countriesMap,
prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)
);
$fields = $service->buildTaskSearchFields();
$expectedFields = ['Id', 'WhoId', 'WhatId', 'AccountId'];
$this->assertEquals($expectedFields, $fields);
}
public function testMapCrmObjects(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$service = new Service(
client: $client,
payloadBuilder: $payloadBuilder,
eventDispatcher: $eventDispatcher,
countriesMap: $countriesMap,
prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class),
);
$sampleTask = [
'WhoId' => '003sampleWhoId',
'AccountId' => 'sampleAccountId',
'WhatId' => 'sampleWhatId',
];
$activityData = $service->mapCrmObjects($sampleTask);
$expectedActivityData = [
'contact' => '003sampleWhoId',
'account' => 'sampleAccountId',
'opportunity' => 'sampleWhatId',
];
$this->assertEquals($expectedActivityData, $activityData);
}
public function testGetInstalledAppVersion(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$queryIterator = $this->createMock(QueryIterator::class);
$queryIterator->expects($this->any())
->method('current')->willReturn([
'SubscriberPackageVersion' => [
'MajorVersion' => '1',
'MinorVersion' => '0',
'PatchVersion' => '1',
'BuildNumber' => '0',
],
]);
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('metadata')
->willReturn($queryIterator);
return $handler;
});
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods(array_diff(get_class_methods(Service::class), ['getInstalledAppVersion']))
->getMock();
$version = $serviceMock->getInstalledAppVersion();
$this->assertEquals('1010', $version);
}
public function testSyncProfiles(): void
{
$userToSearch = null;
$team = $this->createMock(Team::class);
$config = $this->createMockedConfiguration();
$config->expects($this->once())
->method('getId')
->willReturn(1);
$salesforceUser = [
'Email' => '[EMAIL]',
'UserPreferencesLightningExperiencePreferred' => true,
'CallCenterId' => '123',
'Id' => '456',
'ProfileId' => '789',
];
app()->bind(QueryBuilder::class, function () use ($userToSearch) {
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->expects($this->once())
->method('buildGetUsersQuery')
->with($userToSearch)
->willReturn('SELECT * FROM Users');
return $queryBuilder;
});
$queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('query')
->with('SELECT * FROM Users')
->willReturn($queryIterator);
return $handler;
});
$user = $this->createMock(User::class);
$user->expects($this->once())
->method('getId')
->willReturn(123);
$this->mockTeamRepository($team, $salesforceUser, $user);
$profileRepository = $this->createMock(ProfileRepository::class);
$profileRepository->expects($this->once())
->method('updateOrCreateProfile')
->with(
$user,
[
'crm_configuration_id' => 1,
'crm_provider_id' => '456',
],
[
'user_id' => 123,
'edition' => Profile::EDITION_LIGHTNING,
'has_external_cti' => true,
'crm_profile_id' => '789',
]
)
->willReturn(new Profile());
$this->app->instance(ProfileRepository::class, $profileRepository);
$serviceMock = $this->getServiceMock();
$serviceMock->team = $team;
$serviceMock->config = $config;
$result = $serviceMock->syncProfiles($userToSearch);
$this->assertNull($result);
}
public function testSyncProfilesEmailIsNull(): void
{
$userToSearch = $this->createMock(User::class);
$salesforceUser = [
'Email' => null,
];
app()->bind(QueryBuilder::class, function () {
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->expects($this->once())
->method('buildGetUsersQuery')
->with(null)
->willReturn('SELECT * FROM Users');
return $queryBuilder;
});
$queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->exactly(2))
->method('query')
->willReturn($queryIterator);
return $handler;
});
$team = $this->createMock(Team::class);
$user = $this->createMock(User::class);
$this->mockTeamRepository($team, $salesforceUser, $user, false);
$config = $this->createMock(Configuration::class);
$serviceMock = $this->getServiceMock();
$serviceMock->team = $team;
$serviceMock->config = $config;
$profile = $serviceMock->syncProfiles(null);
$this->assertNull($profile);
}
public function testSyncProfilesUserToSearchMatchesCurrentUser(): void
{
$userToSearch = $this->createMock(User::class);
$userToSearch->expects($this->once())
->method('getId')
->willReturn(123);
$team = $this->createMock(Team::class);
$config = $this->createMock(Configuration::class);
$config->expects($this->once())
->method('getId')
->willReturn(1);
$salesforceUser = [
'Email' => '[EMAIL]',
'UserPreferencesLightningExperiencePreferred' => true,
'CallCenterId' => '123',
'Id' => '456',
'ProfileId' => '789',
];
app()->bind(QueryBuilder::class, function () use ($userToSearch) {
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->expects($this->once())
->method('buildGetUsersQuery')
->with($userToSearch)
->willReturn('SELECT * FROM Users');
return $queryBuilder;
});
$queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('query')
->with('SELECT * FROM Users')
->willReturn($queryIterator);
return $handler;
});
$user = $this->createMock(User::class);
$user->expects($this->exactly(2))
->method('getId')
->willReturn(123);
$this->mockTeamRepository($team, $salesforceUser, $user);
$profileRepository = $this->createMock(ProfileRepository::class);
$profileRepository->expects($this->once())
->method('updateOrCreateProfile')
->with(
$user,
[
'crm_configuration_id' => 1,
'crm_provider_id' => '456',
],
[
'user_id' => 123,
'edition' => Profile::EDITION_LIGHTNING,
'has_external_cti' => true,
'crm_profile_id' => '789',
]
)
->willReturn(new Profile());
$this->app->instance(ProfileRepository::class, $profileRepository);
$serviceMock = $this->getServiceMock();
$serviceMock->team = $team;
$serviceMock->config = $config;
$profile = $serviceMock->syncProfiles($userToSearch);
$this->assertInstanceOf(Profile::class, $profile);
}
public function testSyncProfilesWithCustomValidation(): void
{
$userToSearch = null;
$team = $this->createMock(Team::class);
$config = $this->createMockedConfiguration();
$config->expects($this->atLeastOnce()) // Changed from once() to atLeastOnce()
->method('getId')
->willReturn(1);
$salesforceUser = [
'Email' => '[EMAIL]',
'UserPreferencesLightningExperiencePreferred' => true,
'CallCenterId' => '123',
'Id' => '456',
'ProfileId' => '789',
'CustomField' => 'CustomValue',
];
$customRules = [
['field' => 'CustomField', 'value' => 'CustomValue'],
];
$this->mockQueryBuilderAndHandler($userToSearch, [$salesforceUser]);
$user = $this->createMock(User::class);
$user->expects($this->once())
->method('getId')
->willReturn(123);
$this->mockTeamRepository($team, $salesforceUser, $user, true, $customRules);
$this->mockProfileRepository($user);
$serviceMock = $this->getServiceMock();
$serviceMock->team = $team;
$serviceMock->config = $config;
$profile = $serviceMock->syncProfiles($userToSearch);
$this->assertNull($profile);
}
public function testSyncProfilesWithCustomValidationFailing(): void
{
$userToSearch = null;
$team = $this->createMock(Team::class);
$config = $this->createMockedConfiguration();
$salesforceUser = [
'Email' => '[EMAIL]',
'UserPreferencesLightningExperiencePreferred' => true,
'CallCenterId' => '123',
'Id' => '456',
'ProfileId' => '789',
'CustomField' => 'WrongValue',
];
$customRules = [
['field' => 'CustomField', 'value' => 'CustomValue'],
];
$this->mockQueryBuilderAndHandler($userToSearch, [$salesforceUser]);
$teamRepository = $this->getMockForAbstractClass(TeamRepository::class, [], '', false, true, true, ['findActiveTeamMemberByEmail', 'getTeamSetting']);
$teamSettings = $this->createMock(TeamSettings::class);
$teamSettings->method('getValueType')
->willReturn('array');
$teamSettings->method('getValue')
->willReturn(json_encode($customRules));
$teamRepository->expects($this->once())
->method('getTeamSetting')
->with($team, 'custom_profile_validation')
->willReturn($teamSettings);
app()->bind(TeamRepository::class, function () use ($teamRepository) {
return $teamRepository;
});
$profileRepository = $this->createMock(ProfileRepository::class);
$profileRepository->expects($this->never())
->method('updateOrCreateProfile');
$this->app->instance(ProfileRepository::class, $profileRepository);
$serviceMock = $this->getServiceMock();
$serviceMock->team = $team;
$serviceMock->config = $config;
$result = $serviceMock->syncProfiles($userToSearch);
$this->assertNull($result);
}
public function testGetContactRolesFromCrm(): void
{
$contactRoles = [
[
'Id' => '1',
'ContactId' => 'Contact1',
'OpportunityId' => 'Opportunity1',
'Opportunity' => ['OwnerId' => 'Owner1'],
'IsPrimary' => true,
'Role' => 'Decision Maker',
],
];
$expectedResponse = [
[
'id' => '1',
'contactId' => 'Contact1',
'opportunityId' => 'Opportunity1',
'ownerId' => 'Owner1',
'isPrimary' => true,
'role' => 'Decision Maker',
],
];
$this->bindQueryIterator($contactRoles);
$serviceMock = $this->getServiceMock();
$data = $serviceMock->getContactRolesFromCrm(now()->subDay());
$this->assertEquals($expectedResponse, $data);
}
public function testGetContactRolesFromCrmNoResult(): void
{
$this->bindQueryIterator([]);
$serviceMock = $this->getServiceMock();
$data = $serviceMock->getContactRolesFromCrm(now()->subDay());
$this->assertEquals([], $data);
}
public function testSyncContactRoles(): void
{
$contactRoles = [
[
'id' => '1',
'contactId' => 'Contact1',
'opportunityId' => 'Opportunity1',
'ownerId' => 'Owner1',
'isPrimary' => true,
'role' => 'Decision Maker',
],
];
app()->bind(ContactRoleRepository::class, function () {
$contactRoleRepository = $this->createMock(ContactRoleRepository::class);
$contactRoleRepository->expects($this->once())
->method('saveContactRoles');
return $contactRoleRepository;
});
$serviceMock = $this->getServiceMock([
'getContactRolesFromCrm',
'syncRemotelyDeletedContactRoles',
'syncContact',
'syncOpportunity',
]);
$config = $this->createMock(Configuration::class);
$hasMany = $this->createMock(HasManyExtended::class);
$hasMany->expects($this->exactly(2))
->method('where')
->willReturn($hasMany);
$hasMany->expects($this->exactly(2))
->method('first')
->willReturn(
$this->createMock(Contact::class),
$this->createMock(Opportunity::class)
);
$config->expects($this->once())
->method('contacts')
->willReturn($hasMany);
$config->expects($this->once())
->method('opportunities')
->willReturn($hasMany);
$serviceMock->config = $config;
$serviceMock->expects($this->once())
->method('getContactRolesFromCrm')
->willReturn($contactRoles);
$serviceMock->expects($this->once())
->method('syncRemotelyDeletedContactRoles');
$data = $serviceMock->syncContactRoles(now()->subDay());
$this->assertEquals(1, $data);
}
public function testSyncRemotelyDeletedContactRoles(): void
{
$contactRoles = [
[
'id' => '1',
'crm_provider_id' => '1',
],
];
app()->bind(QueryHandler::class, function () use ($contactRoles) {
$queryResults = new QueryResults($contactRoles, 1, true, null);
$handler = $this->createMock(QueryHandler::class);
$handler->method('queryDeleted')
->willReturn($queryResults);
return $handler;
});
app()->bind(ContactRoleRepository::class, function () {
$contactRoleRepository = $this->createMock(ContactRoleRepository::class);
$contactRoleRepository->expects($this->once())
->method('deleteContactRoles');
return $contactRoleRepository;
});
$serviceMock = $this->getServiceMock();
$serviceMock->team = $this->createMock(Team::class);
$data = $this->invokePrivateMethod('syncRemotelyDeletedContactRoles', $serviceMock, []);
$this->assertTrue($data);
}
private function bindQueryIterator(array $queryResult): void
{
/** @var Client $client */
$client = $this->createMock(Client::class);
$queryIterator = new QueryIterator(
$client,
new QueryResults($queryResult, 1, true, null)
);
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('query')
->willReturn($queryIterator);
return $handler;
});
}
public static function getOpportunitySortOrderDataProvider(): array
{
return [
'all open recently updated' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED, ['LastModifiedDate DESC', true]],
'all open recently created' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED, ['CreatedDate DESC', true]],
'all open oldest created' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED, ['CreatedDate ASC', true]],
'all recently updated' => [Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED, ['LastModifiedDate DESC', false]],
'default' => ['unknown', ['LastModifiedDate DESC', true]],
];
}
private function createMockedConfiguration(): Configuration
{
$config = $this->createMock(Configuration::class);
$profilesRelation = $this->getMockBuilder(\Illuminate\Database\Eloquent\Relations\HasMany::class)
->disableOriginalConstructor()
->onlyMethods(['get'])
->addMethods(['where', 'first'])
->getMock();
$profilesRelation->method('where')->willReturnSelf();
$profilesRelation->method('get')->willReturn(collect([]));
$profilesRelation->method('first')->willReturn(null);
$config->method('profiles')->willReturn($profilesRelation);
return $config;
}
private function getServiceMock(array $onlyMethods = []): MockObject&Service
{
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$this->createMock(Client::class),
$this->createMock(PayloadBuilder::class),
$this->createMock(Dispatcher::class),
$this->createMock(CountriesMap::class),
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods($onlyMethods)
->getMock();
$serviceMock->profile = $this->createMock(Profile::class);
return $serviceMock;
}
private function mockQueryBuilderAndHandler($userToSearch, $salesforceUsers): void
{
app()->bind(QueryBuilder::class, function () use ($userToSearch) {
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->expects($this->once())
->method('buildGetUsersQuery')
->with($userToSearch)
->willReturn('SELECT * FROM Users');
return $queryBuilder;
});
$queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults($salesforceUsers, count($salesforceUsers), true, null));
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('query')
->willReturn($queryIterator);
return $handler;
});
}
private function mockTeamRepository(
Team $team,
array $salesforceUser,
?User $user = null,
bool $userSearch = true,
array $customRules = []
): void {
$teamRepository = $this->getMockForAbstractClass(TeamRepository::class, [], '', false, true, true, ['findActiveTeamMemberByEmail', 'getTeamSetting']);
if ($userSearch) {
$teamRepository->expects($this->once())
->method('findActiveTeamMemberByEmail')
->with($team, $salesforceUser['Email'])
->willReturn($user);
}
$teamSettings = $this->createMock(TeamSettings::class);
$teamSettings->method('getValueType')
->willReturn('array');
$teamSettings->method('getValue')
->willReturn(json_encode($customRules));
$teamRepository->expects($this->once())
->method('getTeamSetting')
->with($team, 'custom_profile_validation')
->willReturn($teamSettings);
app()->bind(TeamRepository::class, function () use ($teamRepository) {
return $teamRepository;
});
}
private function mockProfileRepository(User $user): void
{
$profileRepository = $this->createMock(ProfileRepository::class);
$profileRepository->expects($this->once())
->method('updateOrCreateProfile')
->with(
$user,
[
'crm_configuration_id' => 1,
'crm_provider_id' => '456',
],
[
'user_id' => 123,
'edition' => Profile::EDITION_LIGHTNING,
'has_external_cti' => true,
'crm_profile_id' => '789',
]
)
->willReturn(new Profile());
$this->app->instance(ProfileRepository::class, $profileRepository);
}
public function testBuildEnhancedNoteDecodesHtmlEntities(): void
{
$service = $this->getServiceMock(['createRecord']);
$profile = new Profile();
$profile->setAttribute('crm_provider_id', 'owner-123');
$service->profile = $profile;
$service->expects($this->exactly(2))
->method('createRecord')
->willReturnOnConsecutiveCalls('note-id-123', 'link-id-456');
$bodyWithEntities = 'Welch's current challenges and Facebook's Club';
$result = $this->invokePrivateMethod('buildEnhancedNote', $service, [
'Test Title',
$bodyWithEntities,
'object-id-789',
]);
$this->assertEquals('note-id-123', $result);
}
public function testBuildEnhancedNoteSanitizesWithoutQuotes(): void
{
$service = $this->getServiceMock(['createRecord']);
$profile = new Profile();
$profile->setAttribute('crm_provider_id', 'owner-456');
$service->profile = $profile;
$service->expects($this->exactly(2))
->method('createRecord')
->willReturnCallback(function ($type, $data) {
if ($type === 'ContentNote') {
$decoded = base64_decode($data['Content']);
$this->assertStringContainsString("Welch's", $decoded);
$this->assertStringNotContainsString(''', $decoded);
$this->assertStringNotContainsString('&#039;', $decoded);
$this->assertStringContainsString('<script>', $decoded);
return 'note-id-456';
}
return 'link-id-789';
});
$bodyWithMixedContent = "Welch's and <script>alert('xss')</script>";
$result = $this->invokePrivateMethod('buildEnhancedNote', $service, [
'Test Title',
$bodyWithMixedContent,
'object-id-123',
]);
$this->assertEquals('note-id-456', $result);
}
public function testBuildEnhancedNoteConvertsLineBreaks(): void
{
$service = $this->getServiceMock(['createRecord']);
$profile = new Profile();
$profile->setAttribute('crm_provider_id', 'owner-789');
$service->profile = $profile;
$service->expects($this->exactly(2))
->method('createRecord')
->willReturnCallback(function ($type, $data) {
if ($type === 'ContentNote') {
$decoded = base64_decode($data['Content']);
$this->assertStringContainsString('<br>', $decoded);
$this->assertStringNotContainsString('<br />', $decoded);
return 'note-id-789';
}
return 'link-id-012';
});
$bodyWithLineBreaks = "Line 1\nLine 2\nLine 3";
$result = $this->invokePrivateMethod('buildEnhancedNote', $service, [
'Test Title',
$bodyWithLineBreaks,
'object-id-456',
]);
$this->assertEquals('note-id-789', $result);
}
public function testBuildEnhancedNoteHandlesComplexScenario(): void
{
$service = $this->getServiceMock(['createRecord']);
$profile = new Profile();
$profile->setAttribute('crm_provider_id', 'owner-complex');
$service->profile = $profile;
$service->expects($this->exactly(2))
->method('createRecord')
->willReturnCallback(function ($type, $data) {
if ($type === 'ContentNote') {
$decoded = base64_decode($data['Content']);
$this->assertStringContainsString("Welch's", $decoded);
$this->assertStringContainsString("Facebook's Club", $decoded);
$this->assertStringContainsString("Arctics'", $decoded);
$this->assertStringNotContainsString(''', $decoded);
$this->assertStringNotContainsString('&#039;', $decoded);
$this->assertStringContainsString('<br>', $decoded);
$this->assertStringContainsString('<', $decoded);
$this->assertStringContainsString('>', $decoded);
return 'note-complex';
}
return 'link-complex';
});
$complexBody = "Summary:\n---------\nThe call focused on understanding Welch's current challenges and exploring how Arctics' Revenue Growth Management solutions could support their strategic goals.\n\n• John SMith discussed his role as a category advisor for Google and Facebook's Club, emphasizing the importance of market research and advising on product assortment.\n• Madona introduced Arctics' Virtual Shoppers AI, which simulates consumer <behavior> to optimize pricing and promotional strategies.";
$result = $this->invokePrivateMethod('buildEnhancedNote', $service, [
'Jiminny Transcription Summary',
$complexBody,
'task-id-001',
]);
$this->assertEquals('note-complex', $result);
}
public function testSyncRemotelyDeletedObjectsWithErrorHandlingSuccess(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$prospectPhotoPathService,
])
->onlyMethods(['syncRemotelyDeletedObjects'])
->getMock();
// Mock team
$team = $this->createMock(Team::class);
$team->method('getUuid')->willReturn('team-uuid-123');
$serviceMock->team = $team;
// Expect syncRemotelyDeletedObjects to be called once and succeed
$serviceMock->expects($this->once())
->method('syncRemotelyDeletedObjects')
->with(\Jiminny\Enums\CrmObject::ACCOUNT);
// Call the protected method using reflection
$reflection = new \ReflectionClass($serviceMock);
$method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');
$method->setAccessible(true);
// Should not throw any exceptions
$method->invoke($serviceMock, \Jiminny\Enums\CrmObject::ACCOUNT);
$this->assertTrue(true); // Test completed successfully
}
public function testSyncRemotelyDeletedObjectsWithErrorHandlingFailure(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$prospectPhotoPathService,
])
->onlyMethods(['syncRemotelyDeletedObjects'])
->getMock();
// Mock team
$team = $this->createMock(Team::class);
$team->method('getUuid')->willReturn('team-uuid-456');
$serviceMock->team = $team;
// Mock logger to verify warning is logged
$logger = $this->createMock(\Psr\Log\LoggerInterface::class);
// Use reflection to set the protected logger property
$reflection = new \ReflectionClass($serviceMock);
$loggerProperty = $reflection->getProperty('logger');
$loggerProperty->setAccessible(true);
$loggerProperty->setValue($serviceMock, $logger);
$exception = new \Exception('Sync failed due to API error');
// Expect syncRemotelyDeletedObjects to throw an exception
$serviceMock->expects($this->once())
->method('syncRemotelyDeletedObjects')
->with(\Jiminny\Enums\CrmObject::CONTACT)
->willThrowException($exception);
// Expect warning to be logged with correct message and parameters
$logger->expects($this->once())
->method('warning')
->with(
'[Salesforce] Remotely deleted objects sync failed',
[
'objectType' => 'contact',
'teamId' => 'team-uuid-456',
'reason' => 'Sync failed due to API error',
]
);
// Call the protected method using reflection
$reflection = new \ReflectionClass($serviceMock);
$method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');
$method->setAccessible(true);
// Should not re-throw the exception, just log it
$method->invoke($serviceMock, \Jiminny\Enums\CrmObject::CONTACT);
$this->assertTrue(true); // Test completed successfully
}
public function testSyncRemotelyDeletedObjectsWithErrorHandlingWithLogParams(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$prospectPhotoPathService,
])
->onlyMethods(['syncRemotelyDeletedObjects'])
->getMock();
// Mock team
$team = $this->createMock(Team::class);
$team->method('getUuid')->willReturn('team-uuid-789');
$serviceMock->team = $team;
// Mock logger to verify warning is logged
$logger = $this->createMock(\Psr\Log\LoggerInterface::class);
// Use reflection to set the protected logger property
$loggerReflection = new \ReflectionClass($serviceMock);
$loggerProperty = $loggerReflection->getProperty('logger');
$loggerProperty->setAccessible(true);
$loggerProperty->setValue($serviceMock, $logger);
$exception = new \Exception('Network timeout');
// Expect syncRemotelyDeletedObjects to throw an exception
$serviceMock->expects($this->once())
->method('syncRemotelyDeletedObjects')
->with(\Jiminny\Enums\CrmObject::OPPORTUNITY)
->willThrowException($exception);
// Additional log parameters
$logParams = [
'syncType' => 'full',
'batchSize' => 100,
];
// Expect warning to be logged with merged parameters
$logger->expects($this->once())
->method('warning')
->with(
'[Salesforce] Remotely deleted objects sync failed',
[
'objectType' => 'opportunity',
'teamId' => 'team-uuid-789',
'reason' => 'Network timeout',
'syncType' => 'full',
'batchSize' => 100,
]
);
// Call the protected method using reflection
$reflection = new \ReflectionClass($serviceMock);
$method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');
$method->setAccessible(true);...
|
[{"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-20963-fix-import-on-deleted-entity, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.09607713,"height":0.025538707},"on_screen":true,"help_text":"Git Branch: JY-20963-fix-import-on-deleted-entity","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.85638297,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"ServiceTest","depth":6,"bounds":{"left":0.87167555,"top":0.019952115,"width":0.043882977,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'ServiceTest'","depth":6,"bounds":{"left":0.9155585,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'ServiceTest'","depth":6,"bounds":{"left":0.9268617,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.9381649,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.96609044,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9773936,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.9886968,"top":0.019952115,"width":0.011303186,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Built-in Preview","depth":4,"bounds":{"left":0.37101063,"top":0.13088587,"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":"Chrome","depth":4,"bounds":{"left":0.37965426,"top":0.13088587,"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":"Firefox","depth":4,"bounds":{"left":0.3882979,"top":0.13088587,"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":"Safari","depth":4,"bounds":{"left":0.39694148,"top":0.13088587,"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":"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":"4","depth":4,"bounds":{"left":0.35472074,"top":0.10055866,"width":0.007978723,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"32","depth":4,"bounds":{"left":0.36469415,"top":0.10055866,"width":0.010305851,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"168","depth":4,"bounds":{"left":0.37699467,"top":0.10055866,"width":0.011968086,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"bounds":{"left":0.39095744,"top":0.10055866,"width":0.00731383,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"28","depth":4,"bounds":{"left":0.40026596,"top":0.10055866,"width":0.009973404,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.4119016,"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.4192154,"top":0.09896249,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Services\\Crm\\Salesforce;\n\nuse Illuminate\\Database\\Eloquent\\Relations\\HasMany;\nuse Illuminate\\Events\\Dispatcher;\nuse Illuminate\\Support\\Facades\\Cache;\nuse Jiminny\\Component\\Country\\CountriesMap;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Enums\\CrmObject;\nuse Jiminny\\Exceptions\\InvalidArgumentException;\nuse Jiminny\\Exceptions\\NoResultsException;\nuse Jiminny\\Integrations\\PlaybookResolver;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Crm\\Configuration;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\TeamSettings;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\ContactRoleRepository;\nuse Jiminny\\Repositories\\Crm\\FieldDataRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\Salesforce\\Client;\nuse Jiminny\\Services\\Crm\\Salesforce\\PayloadBuilder;\nuse Jiminny\\Services\\Crm\\Salesforce\\QueryBuilder;\nuse Jiminny\\Services\\Crm\\Salesforce\\QueryHandler;\nuse Jiminny\\Services\\Crm\\Salesforce\\QueryIterator;\nuse Jiminny\\Services\\Crm\\Salesforce\\QueryResults;\nuse Jiminny\\Services\\Crm\\Salesforce\\Service;\nuse PHPUnit\\Framework\\MockObject\\MockObject;\nuse Tests\\TestCase;\nuse Tests\\Unit\\Traits\\TestPrivateMethod;\n\nclass ServiceTest extends TestCase\n{\n use TestPrivateMethod;\n\n public function testFetchAndAssociateRelatedActivity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $payloadBuilder->method('addCustomLogicFieldsPayload')\n ->willReturnCallback(function ($activity, $payload) {\n return $payload;\n });\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods(['fetchRelatedActivity', 'getPlaybook', 'getPlaybookCategory', 'updateRecord'])\n ->getMock();\n\n $serviceMock->expects($this->once())\n ->method('fetchRelatedActivity')\n ->willReturn([\n 'Id' => 'testId',\n 'Type' => null,\n 'OwnerId' => 'testerUser',\n 'Description' => 'Test description',\n ]);\n\n $user = $this->createMock(User::class);\n $team = $this->createMock(Team::class);\n $user->method('getAttribute')->with('team')->willReturn($team);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityField')->willReturn(null);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_EVENT);\n\n $serviceMock->expects($this->once())\n ->method('getPlaybook')\n ->with($user)\n ->willReturn($playbook);\n\n $serviceMock->expects($this->never())\n ->method('getPlaybookCategory');\n\n $serviceMock->expects($this->never())\n ->method('updateRecord');\n\n $fieldDataRepository = $this->createMock(FieldDataRepository::class);\n $fieldDataRepository->method('getActivityFieldData')->willReturn(collect([]));\n app()->instance(FieldDataRepository::class, $fieldDataRepository);\n\n $config = $this->createMock(Configuration::class);\n $profilesRelation = $this->getMockBuilder(\\Illuminate\\Database\\Eloquent\\Relations\\HasMany::class)\n ->disableOriginalConstructor()\n ->onlyMethods(['get'])\n ->addMethods(['where'])\n ->getMock();\n $profilesRelation->method('where')->willReturnSelf();\n $profilesRelation->method('get')->willReturn(collect([]));\n $config->method('profiles')->willReturn($profilesRelation);\n\n $serviceMock->config = $config;\n $serviceMock->profile = null;\n\n $actualStartTime = \\Carbon\\Carbon::now();\n\n $activity = $this->getMockBuilder(Activity::class)\n ->disableOriginalConstructor()\n ->onlyMethods(['update', 'hasProspect'])\n ->getMock();\n\n $activity->method('update')->willReturn(true);\n $activity->method('hasProspect')->willReturn(true);\n\n $activity->type = Activity::TYPE_CONFERENCE;\n $activity->provider = Activity::PROVIDER_TWILIO;\n $activity->lead_id = 1;\n $activity->user_id = 0;\n $activity->id_string = 'test-activity-id';\n $activity->user = $user;\n\n $activity->actual_start_time = $actualStartTime;\n $activity->uuid = 'c53d8320-f556-4cee-a2f8-5f232f454ca4';\n\n app()->bind(PlaybookResolver::class, function () use ($user) {\n $playbook = $this->createMock(Playbook::class);\n $playbookResolver = $this->createMock(PlaybookResolver::class);\n $playbookResolver->expects($this->once())\n ->method('resolvePlaybookByUser')\n ->with($user)\n ->willReturn($playbook);\n\n return $playbookResolver;\n });\n\n $data = $serviceMock->fetchAndAssociateRelatedActivity($activity);\n\n $this->assertInstanceOf(Activity::class, $data);\n $this->assertEquals(Activity::TYPE_CONFERENCE, $data->getType());\n $this->assertEquals($actualStartTime->getTimestamp(), $data->getActualStartTime()->getTimestamp());\n }\n\n public function testFetchAndAssociateRelatedActivitySkipsForTaskBasedPlaybook(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods(['fetchRelatedActivity', 'getPlaybook'])\n ->getMock();\n\n $user = $this->createMock(User::class);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n $playbook->method('getId')->willReturn(123);\n\n $serviceMock->expects($this->once())\n ->method('getPlaybook')\n ->with($user)\n ->willReturn($playbook);\n\n $serviceMock->expects($this->never())\n ->method('fetchRelatedActivity');\n\n $activity = $this->getMockBuilder(Activity::class)\n ->disableOriginalConstructor()\n ->onlyMethods(['hasProspect', 'getUuid'])\n ->getMock();\n\n $activity->method('hasProspect')->willReturn(true);\n $activity->method('getUuid')->willReturn('c53d8320-f556-4cee-a2f8-5f232f454ca4');\n $activity->type = Activity::TYPE_CONFERENCE;\n $activity->actual_start_time = \\Carbon\\Carbon::now();\n $activity->user = $user;\n\n $result = $serviceMock->fetchAndAssociateRelatedActivity($activity);\n\n $this->assertNull($result);\n }\n\n public function testFetchAndAssociateRelatedActivityReturnsNullForNonConference(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $serviceMock = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)\n );\n\n $activity = $this->getMockBuilder(Activity::class)\n ->disableOriginalConstructor()\n ->getMock();\n\n $activity->type = Activity::TYPE_SOFTPHONE;\n\n $result = $serviceMock->fetchAndAssociateRelatedActivity($activity);\n\n $this->assertNull($result);\n }\n\n public function testFetchAndAssociateRelatedActivityReturnsNullWhenNoStartTime(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $serviceMock = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)\n );\n\n\n $activity = $this->getMockBuilder(Activity::class)\n ->disableOriginalConstructor()\n ->getMock();\n\n $activity->type = Activity::TYPE_CONFERENCE;\n $activity->actual_start_time = null;\n $activity->scheduled_start_time = null;\n\n $result = $serviceMock->fetchAndAssociateRelatedActivity($activity);\n\n $this->assertNull($result);\n }\n\n public function testFetchAndAssociateRelatedActivityReturnsNullWhenNoProspect(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods(['getPlaybook'])\n ->getMock();\n\n $serviceMock->expects($this->never())\n ->method('getPlaybook');\n\n $activity = $this->getMockBuilder(Activity::class)\n ->disableOriginalConstructor()\n ->onlyMethods(['hasProspect', 'getUuid'])\n ->getMock();\n\n $activity->method('hasProspect')->willReturn(false);\n $activity->method('getUuid')->willReturn('c53d8320-f556-4cee-a2f8-5f232f454ca4');\n $activity->type = Activity::TYPE_CONFERENCE;\n $activity->actual_start_time = \\Carbon\\Carbon::now();\n\n $result = $serviceMock->fetchAndAssociateRelatedActivity($activity);\n\n $this->assertNull($result);\n }\n\n public function testMatchExactlyByEmail(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods([])\n ->getMock();\n\n $profile = new Profile();\n $profile->setAttribute('id', bin2hex(random_bytes(8)));\n $serviceMock->profile = $profile;\n\n $team = $this->createMock(Team::class);\n $serviceMock->team = $team;\n\n $data = $serviceMock->matchExactlyByEmail(bin2hex(random_bytes(8)) . 'test_email@testserver.com');\n\n $this->assertEquals(null, $data);\n }\n\n public function testMatchDomainFromEmail(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $queryIterator = $this->createMock(QueryIterator::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $config = $this->createMock(Configuration::class);\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('search')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods(['convertCrmData'])\n ->getMock();\n\n $profile = new Profile();\n $profile->account_fields = 'Field1, Field2, Field3';\n $serviceMock->profile = $profile;\n\n $serviceMock->expects($this->once())\n ->method('convertCrmData')\n ->willReturn(['test']);\n\n $this->app->bind(QueryBuilder::class, function () {\n $queryBuilder = $this->createMock(QueryBuilder::class);\n $queryBuilder->expects($this->once())\n ->method('buildMatchByDomainQuery')\n ->with('test_email@testserver.com')\n ->willReturn('FIND {test_email@testserver.com} IN ALL FIELDS RETURNING Account(Id)');\n\n return $queryBuilder;\n });\n\n $team = $this->createMock(Team::class);\n\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n\n $data = $serviceMock->matchByDomain('test_email@testserver.com');\n\n $this->assertEquals(['test'], $data);\n }\n\n public function testBuildTaskSearchFields(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)\n );\n\n $fields = $service->buildTaskSearchFields();\n\n $expectedFields = ['Id', 'WhoId', 'WhatId', 'AccountId'];\n\n $this->assertEquals($expectedFields, $fields);\n }\n\n public function testMapCrmObjects(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class),\n );\n\n $sampleTask = [\n 'WhoId' => '003sampleWhoId',\n 'AccountId' => 'sampleAccountId',\n 'WhatId' => 'sampleWhatId',\n ];\n\n $activityData = $service->mapCrmObjects($sampleTask);\n\n $expectedActivityData = [\n 'contact' => '003sampleWhoId',\n 'account' => 'sampleAccountId',\n 'opportunity' => 'sampleWhatId',\n ];\n\n $this->assertEquals($expectedActivityData, $activityData);\n }\n\n public function testGetInstalledAppVersion(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n $queryIterator = $this->createMock(QueryIterator::class);\n $queryIterator->expects($this->any())\n ->method('current')->willReturn([\n 'SubscriberPackageVersion' => [\n 'MajorVersion' => '1',\n 'MinorVersion' => '0',\n 'PatchVersion' => '1',\n 'BuildNumber' => '0',\n ],\n ]);\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('metadata')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods(array_diff(get_class_methods(Service::class), ['getInstalledAppVersion']))\n ->getMock();\n\n $version = $serviceMock->getInstalledAppVersion();\n\n $this->assertEquals('1010', $version);\n }\n\n public function testSyncProfiles(): void\n {\n $userToSearch = null;\n\n $team = $this->createMock(Team::class);\n $config = $this->createMockedConfiguration();\n $config->expects($this->once())\n ->method('getId')\n ->willReturn(1);\n\n $salesforceUser = [\n 'Email' => 'test@example.com',\n 'UserPreferencesLightningExperiencePreferred' => true,\n 'CallCenterId' => '123',\n 'Id' => '456',\n 'ProfileId' => '789',\n ];\n\n app()->bind(QueryBuilder::class, function () use ($userToSearch) {\n $queryBuilder = $this->createMock(QueryBuilder::class);\n $queryBuilder->expects($this->once())\n ->method('buildGetUsersQuery')\n ->with($userToSearch)\n ->willReturn('SELECT * FROM Users');\n\n return $queryBuilder;\n });\n\n $queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('query')\n ->with('SELECT * FROM Users')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n\n $user = $this->createMock(User::class);\n $user->expects($this->once())\n ->method('getId')\n ->willReturn(123);\n\n $this->mockTeamRepository($team, $salesforceUser, $user);\n\n $profileRepository = $this->createMock(ProfileRepository::class);\n $profileRepository->expects($this->once())\n ->method('updateOrCreateProfile')\n ->with(\n $user,\n [\n 'crm_configuration_id' => 1,\n 'crm_provider_id' => '456',\n ],\n [\n 'user_id' => 123,\n 'edition' => Profile::EDITION_LIGHTNING,\n 'has_external_cti' => true,\n 'crm_profile_id' => '789',\n ]\n )\n ->willReturn(new Profile());\n\n $this->app->instance(ProfileRepository::class, $profileRepository);\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n $result = $serviceMock->syncProfiles($userToSearch);\n\n $this->assertNull($result);\n }\n\n public function testSyncProfilesEmailIsNull(): void\n {\n $userToSearch = $this->createMock(User::class);\n\n $salesforceUser = [\n 'Email' => null,\n ];\n\n app()->bind(QueryBuilder::class, function () {\n $queryBuilder = $this->createMock(QueryBuilder::class);\n $queryBuilder->expects($this->once())\n ->method('buildGetUsersQuery')\n ->with(null)\n ->willReturn('SELECT * FROM Users');\n\n return $queryBuilder;\n });\n\n $queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->exactly(2))\n ->method('query')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n\n $team = $this->createMock(Team::class);\n $user = $this->createMock(User::class);\n $this->mockTeamRepository($team, $salesforceUser, $user, false);\n\n $config = $this->createMock(Configuration::class);\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n\n $profile = $serviceMock->syncProfiles(null);\n\n $this->assertNull($profile);\n }\n\n public function testSyncProfilesUserToSearchMatchesCurrentUser(): void\n {\n $userToSearch = $this->createMock(User::class);\n $userToSearch->expects($this->once())\n ->method('getId')\n ->willReturn(123);\n\n $team = $this->createMock(Team::class);\n $config = $this->createMock(Configuration::class);\n $config->expects($this->once())\n ->method('getId')\n ->willReturn(1);\n\n $salesforceUser = [\n 'Email' => 'test@example.com',\n 'UserPreferencesLightningExperiencePreferred' => true,\n 'CallCenterId' => '123',\n 'Id' => '456',\n 'ProfileId' => '789',\n ];\n\n app()->bind(QueryBuilder::class, function () use ($userToSearch) {\n $queryBuilder = $this->createMock(QueryBuilder::class);\n $queryBuilder->expects($this->once())\n ->method('buildGetUsersQuery')\n ->with($userToSearch)\n ->willReturn('SELECT * FROM Users');\n\n return $queryBuilder;\n });\n\n $queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('query')\n ->with('SELECT * FROM Users')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n\n $user = $this->createMock(User::class);\n $user->expects($this->exactly(2))\n ->method('getId')\n ->willReturn(123);\n\n $this->mockTeamRepository($team, $salesforceUser, $user);\n\n $profileRepository = $this->createMock(ProfileRepository::class);\n $profileRepository->expects($this->once())\n ->method('updateOrCreateProfile')\n ->with(\n $user,\n [\n 'crm_configuration_id' => 1,\n 'crm_provider_id' => '456',\n ],\n [\n 'user_id' => 123,\n 'edition' => Profile::EDITION_LIGHTNING,\n 'has_external_cti' => true,\n 'crm_profile_id' => '789',\n ]\n )\n ->willReturn(new Profile());\n\n $this->app->instance(ProfileRepository::class, $profileRepository);\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n $profile = $serviceMock->syncProfiles($userToSearch);\n\n $this->assertInstanceOf(Profile::class, $profile);\n }\n\n public function testSyncProfilesWithCustomValidation(): void\n {\n $userToSearch = null;\n\n $team = $this->createMock(Team::class);\n $config = $this->createMockedConfiguration();\n $config->expects($this->atLeastOnce()) // Changed from once() to atLeastOnce()\n ->method('getId')\n ->willReturn(1);\n\n $salesforceUser = [\n 'Email' => 'test@example.com',\n 'UserPreferencesLightningExperiencePreferred' => true,\n 'CallCenterId' => '123',\n 'Id' => '456',\n 'ProfileId' => '789',\n 'CustomField' => 'CustomValue',\n ];\n\n $customRules = [\n ['field' => 'CustomField', 'value' => 'CustomValue'],\n ];\n\n $this->mockQueryBuilderAndHandler($userToSearch, [$salesforceUser]);\n\n $user = $this->createMock(User::class);\n $user->expects($this->once())\n ->method('getId')\n ->willReturn(123);\n\n $this->mockTeamRepository($team, $salesforceUser, $user, true, $customRules);\n\n $this->mockProfileRepository($user);\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n $profile = $serviceMock->syncProfiles($userToSearch);\n\n $this->assertNull($profile);\n }\n\n public function testSyncProfilesWithCustomValidationFailing(): void\n {\n $userToSearch = null;\n\n $team = $this->createMock(Team::class);\n $config = $this->createMockedConfiguration();\n\n $salesforceUser = [\n 'Email' => 'test@example.com',\n 'UserPreferencesLightningExperiencePreferred' => true,\n 'CallCenterId' => '123',\n 'Id' => '456',\n 'ProfileId' => '789',\n 'CustomField' => 'WrongValue',\n ];\n\n $customRules = [\n ['field' => 'CustomField', 'value' => 'CustomValue'],\n ];\n\n $this->mockQueryBuilderAndHandler($userToSearch, [$salesforceUser]);\n\n $teamRepository = $this->getMockForAbstractClass(TeamRepository::class, [], '', false, true, true, ['findActiveTeamMemberByEmail', 'getTeamSetting']);\n\n $teamSettings = $this->createMock(TeamSettings::class);\n $teamSettings->method('getValueType')\n ->willReturn('array');\n\n $teamSettings->method('getValue')\n ->willReturn(json_encode($customRules));\n\n $teamRepository->expects($this->once())\n ->method('getTeamSetting')\n ->with($team, 'custom_profile_validation')\n ->willReturn($teamSettings);\n\n app()->bind(TeamRepository::class, function () use ($teamRepository) {\n return $teamRepository;\n });\n\n $profileRepository = $this->createMock(ProfileRepository::class);\n $profileRepository->expects($this->never())\n ->method('updateOrCreateProfile');\n\n $this->app->instance(ProfileRepository::class, $profileRepository);\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n $result = $serviceMock->syncProfiles($userToSearch);\n\n $this->assertNull($result);\n }\n\n public function testGetContactRolesFromCrm(): void\n {\n $contactRoles = [\n [\n 'Id' => '1',\n 'ContactId' => 'Contact1',\n 'OpportunityId' => 'Opportunity1',\n 'Opportunity' => ['OwnerId' => 'Owner1'],\n 'IsPrimary' => true,\n 'Role' => 'Decision Maker',\n ],\n ];\n\n $expectedResponse = [\n [\n 'id' => '1',\n 'contactId' => 'Contact1',\n 'opportunityId' => 'Opportunity1',\n 'ownerId' => 'Owner1',\n 'isPrimary' => true,\n 'role' => 'Decision Maker',\n ],\n ];\n\n $this->bindQueryIterator($contactRoles);\n\n $serviceMock = $this->getServiceMock();\n\n $data = $serviceMock->getContactRolesFromCrm(now()->subDay());\n\n $this->assertEquals($expectedResponse, $data);\n }\n\n public function testGetContactRolesFromCrmNoResult(): void\n {\n $this->bindQueryIterator([]);\n\n $serviceMock = $this->getServiceMock();\n\n $data = $serviceMock->getContactRolesFromCrm(now()->subDay());\n\n $this->assertEquals([], $data);\n }\n\n public function testSyncContactRoles(): void\n {\n $contactRoles = [\n [\n 'id' => '1',\n 'contactId' => 'Contact1',\n 'opportunityId' => 'Opportunity1',\n 'ownerId' => 'Owner1',\n 'isPrimary' => true,\n 'role' => 'Decision Maker',\n ],\n ];\n\n app()->bind(ContactRoleRepository::class, function () {\n $contactRoleRepository = $this->createMock(ContactRoleRepository::class);\n $contactRoleRepository->expects($this->once())\n ->method('saveContactRoles');\n\n return $contactRoleRepository;\n });\n\n $serviceMock = $this->getServiceMock([\n 'getContactRolesFromCrm',\n 'syncRemotelyDeletedContactRoles',\n 'syncContact',\n 'syncOpportunity',\n ]);\n\n $config = $this->createMock(Configuration::class);\n $hasMany = $this->createMock(HasManyExtended::class);\n $hasMany->expects($this->exactly(2))\n ->method('where')\n ->willReturn($hasMany);\n\n $hasMany->expects($this->exactly(2))\n ->method('first')\n ->willReturn(\n $this->createMock(Contact::class),\n $this->createMock(Opportunity::class)\n );\n\n $config->expects($this->once())\n ->method('contacts')\n ->willReturn($hasMany);\n $config->expects($this->once())\n ->method('opportunities')\n ->willReturn($hasMany);\n\n $serviceMock->config = $config;\n\n $serviceMock->expects($this->once())\n ->method('getContactRolesFromCrm')\n ->willReturn($contactRoles);\n\n $serviceMock->expects($this->once())\n ->method('syncRemotelyDeletedContactRoles');\n\n $data = $serviceMock->syncContactRoles(now()->subDay());\n\n $this->assertEquals(1, $data);\n }\n\n public function testSyncRemotelyDeletedContactRoles(): void\n {\n $contactRoles = [\n [\n 'id' => '1',\n 'crm_provider_id' => '1',\n ],\n ];\n\n app()->bind(QueryHandler::class, function () use ($contactRoles) {\n $queryResults = new QueryResults($contactRoles, 1, true, null);\n\n $handler = $this->createMock(QueryHandler::class);\n $handler->method('queryDeleted')\n ->willReturn($queryResults);\n\n return $handler;\n });\n\n app()->bind(ContactRoleRepository::class, function () {\n $contactRoleRepository = $this->createMock(ContactRoleRepository::class);\n $contactRoleRepository->expects($this->once())\n ->method('deleteContactRoles');\n\n return $contactRoleRepository;\n });\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $this->createMock(Team::class);\n\n $data = $this->invokePrivateMethod('syncRemotelyDeletedContactRoles', $serviceMock, []);\n\n $this->assertTrue($data);\n }\n\n private function bindQueryIterator(array $queryResult): void\n {\n /** @var Client $client */\n $client = $this->createMock(Client::class);\n $queryIterator = new QueryIterator(\n $client,\n new QueryResults($queryResult, 1, true, null)\n );\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('query')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n }\n\n public static function getOpportunitySortOrderDataProvider(): array\n {\n return [\n 'all open recently updated' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED, ['LastModifiedDate DESC', true]],\n 'all open recently created' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED, ['CreatedDate DESC', true]],\n 'all open oldest created' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED, ['CreatedDate ASC', true]],\n 'all recently updated' => [Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED, ['LastModifiedDate DESC', false]],\n 'default' => ['unknown', ['LastModifiedDate DESC', true]],\n ];\n }\n\n private function createMockedConfiguration(): Configuration\n {\n $config = $this->createMock(Configuration::class);\n $profilesRelation = $this->getMockBuilder(\\Illuminate\\Database\\Eloquent\\Relations\\HasMany::class)\n ->disableOriginalConstructor()\n ->onlyMethods(['get'])\n ->addMethods(['where', 'first'])\n ->getMock();\n $profilesRelation->method('where')->willReturnSelf();\n $profilesRelation->method('get')->willReturn(collect([]));\n $profilesRelation->method('first')->willReturn(null);\n $config->method('profiles')->willReturn($profilesRelation);\n\n return $config;\n }\n\n private function getServiceMock(array $onlyMethods = []): MockObject&Service\n {\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $this->createMock(Client::class),\n $this->createMock(PayloadBuilder::class),\n $this->createMock(Dispatcher::class),\n $this->createMock(CountriesMap::class),\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods($onlyMethods)\n ->getMock();\n\n $serviceMock->profile = $this->createMock(Profile::class);\n\n return $serviceMock;\n }\n\n private function mockQueryBuilderAndHandler($userToSearch, $salesforceUsers): void\n {\n app()->bind(QueryBuilder::class, function () use ($userToSearch) {\n $queryBuilder = $this->createMock(QueryBuilder::class);\n $queryBuilder->expects($this->once())\n ->method('buildGetUsersQuery')\n ->with($userToSearch)\n ->willReturn('SELECT * FROM Users');\n\n return $queryBuilder;\n });\n\n $queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults($salesforceUsers, count($salesforceUsers), true, null));\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('query')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n }\n\n private function mockTeamRepository(\n Team $team,\n array $salesforceUser,\n ?User $user = null,\n bool $userSearch = true,\n array $customRules = []\n ): void {\n $teamRepository = $this->getMockForAbstractClass(TeamRepository::class, [], '', false, true, true, ['findActiveTeamMemberByEmail', 'getTeamSetting']);\n\n if ($userSearch) {\n $teamRepository->expects($this->once())\n ->method('findActiveTeamMemberByEmail')\n ->with($team, $salesforceUser['Email'])\n ->willReturn($user);\n }\n\n $teamSettings = $this->createMock(TeamSettings::class);\n $teamSettings->method('getValueType')\n ->willReturn('array');\n\n $teamSettings->method('getValue')\n ->willReturn(json_encode($customRules));\n\n $teamRepository->expects($this->once())\n ->method('getTeamSetting')\n ->with($team, 'custom_profile_validation')\n ->willReturn($teamSettings);\n\n app()->bind(TeamRepository::class, function () use ($teamRepository) {\n return $teamRepository;\n });\n }\n\n private function mockProfileRepository(User $user): void\n {\n $profileRepository = $this->createMock(ProfileRepository::class);\n $profileRepository->expects($this->once())\n ->method('updateOrCreateProfile')\n ->with(\n $user,\n [\n 'crm_configuration_id' => 1,\n 'crm_provider_id' => '456',\n ],\n [\n 'user_id' => 123,\n 'edition' => Profile::EDITION_LIGHTNING,\n 'has_external_cti' => true,\n 'crm_profile_id' => '789',\n ]\n )\n ->willReturn(new Profile());\n\n $this->app->instance(ProfileRepository::class, $profileRepository);\n }\n\n public function testBuildEnhancedNoteDecodesHtmlEntities(): void\n {\n $service = $this->getServiceMock(['createRecord']);\n\n $profile = new Profile();\n $profile->setAttribute('crm_provider_id', 'owner-123');\n $service->profile = $profile;\n\n $service->expects($this->exactly(2))\n ->method('createRecord')\n ->willReturnOnConsecutiveCalls('note-id-123', 'link-id-456');\n\n $bodyWithEntities = 'Welch's current challenges and Facebook's Club';\n\n $result = $this->invokePrivateMethod('buildEnhancedNote', $service, [\n 'Test Title',\n $bodyWithEntities,\n 'object-id-789',\n ]);\n\n $this->assertEquals('note-id-123', $result);\n }\n\n public function testBuildEnhancedNoteSanitizesWithoutQuotes(): void\n {\n $service = $this->getServiceMock(['createRecord']);\n\n $profile = new Profile();\n $profile->setAttribute('crm_provider_id', 'owner-456');\n $service->profile = $profile;\n\n $service->expects($this->exactly(2))\n ->method('createRecord')\n ->willReturnCallback(function ($type, $data) {\n if ($type === 'ContentNote') {\n $decoded = base64_decode($data['Content']);\n $this->assertStringContainsString(\"Welch's\", $decoded);\n $this->assertStringNotContainsString(''', $decoded);\n $this->assertStringNotContainsString('&#039;', $decoded);\n $this->assertStringContainsString('<script>', $decoded);\n\n return 'note-id-456';\n }\n\n return 'link-id-789';\n });\n\n $bodyWithMixedContent = \"Welch's and <script>alert('xss')</script>\";\n\n $result = $this->invokePrivateMethod('buildEnhancedNote', $service, [\n 'Test Title',\n $bodyWithMixedContent,\n 'object-id-123',\n ]);\n\n $this->assertEquals('note-id-456', $result);\n }\n\n public function testBuildEnhancedNoteConvertsLineBreaks(): void\n {\n $service = $this->getServiceMock(['createRecord']);\n\n $profile = new Profile();\n $profile->setAttribute('crm_provider_id', 'owner-789');\n $service->profile = $profile;\n\n $service->expects($this->exactly(2))\n ->method('createRecord')\n ->willReturnCallback(function ($type, $data) {\n if ($type === 'ContentNote') {\n $decoded = base64_decode($data['Content']);\n $this->assertStringContainsString('<br>', $decoded);\n $this->assertStringNotContainsString('<br />', $decoded);\n\n return 'note-id-789';\n }\n\n return 'link-id-012';\n });\n\n $bodyWithLineBreaks = \"Line 1\\nLine 2\\nLine 3\";\n\n $result = $this->invokePrivateMethod('buildEnhancedNote', $service, [\n 'Test Title',\n $bodyWithLineBreaks,\n 'object-id-456',\n ]);\n\n $this->assertEquals('note-id-789', $result);\n }\n\n public function testBuildEnhancedNoteHandlesComplexScenario(): void\n {\n $service = $this->getServiceMock(['createRecord']);\n\n $profile = new Profile();\n $profile->setAttribute('crm_provider_id', 'owner-complex');\n $service->profile = $profile;\n\n $service->expects($this->exactly(2))\n ->method('createRecord')\n ->willReturnCallback(function ($type, $data) {\n if ($type === 'ContentNote') {\n $decoded = base64_decode($data['Content']);\n\n $this->assertStringContainsString(\"Welch's\", $decoded);\n $this->assertStringContainsString(\"Facebook's Club\", $decoded);\n $this->assertStringContainsString(\"Arctics'\", $decoded);\n $this->assertStringNotContainsString(''', $decoded);\n $this->assertStringNotContainsString('&#039;', $decoded);\n $this->assertStringContainsString('<br>', $decoded);\n $this->assertStringContainsString('<', $decoded);\n $this->assertStringContainsString('>', $decoded);\n\n return 'note-complex';\n }\n\n return 'link-complex';\n });\n\n $complexBody = \"Summary:\\n---------\\nThe call focused on understanding Welch's current challenges and exploring how Arctics' Revenue Growth Management solutions could support their strategic goals.\\n\\n• John SMith discussed his role as a category advisor for Google and Facebook's Club, emphasizing the importance of market research and advising on product assortment.\\n• Madona introduced Arctics' Virtual Shoppers AI, which simulates consumer <behavior> to optimize pricing and promotional strategies.\";\n\n $result = $this->invokePrivateMethod('buildEnhancedNote', $service, [\n 'Jiminny Transcription Summary',\n $complexBody,\n 'task-id-001',\n ]);\n\n $this->assertEquals('note-complex', $result);\n }\n\n public function testSyncRemotelyDeletedObjectsWithErrorHandlingSuccess(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['syncRemotelyDeletedObjects'])\n ->getMock();\n\n // Mock team\n $team = $this->createMock(Team::class);\n $team->method('getUuid')->willReturn('team-uuid-123');\n $serviceMock->team = $team;\n\n // Expect syncRemotelyDeletedObjects to be called once and succeed\n $serviceMock->expects($this->once())\n ->method('syncRemotelyDeletedObjects')\n ->with(\\Jiminny\\Enums\\CrmObject::ACCOUNT);\n\n // Call the protected method using reflection\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');\n $method->setAccessible(true);\n\n // Should not throw any exceptions\n $method->invoke($serviceMock, \\Jiminny\\Enums\\CrmObject::ACCOUNT);\n\n $this->assertTrue(true); // Test completed successfully\n }\n\n public function testSyncRemotelyDeletedObjectsWithErrorHandlingFailure(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['syncRemotelyDeletedObjects'])\n ->getMock();\n\n // Mock team\n $team = $this->createMock(Team::class);\n $team->method('getUuid')->willReturn('team-uuid-456');\n $serviceMock->team = $team;\n\n // Mock logger to verify warning is logged\n $logger = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n\n // Use reflection to set the protected logger property\n $reflection = new \\ReflectionClass($serviceMock);\n $loggerProperty = $reflection->getProperty('logger');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($serviceMock, $logger);\n\n $exception = new \\Exception('Sync failed due to API error');\n\n // Expect syncRemotelyDeletedObjects to throw an exception\n $serviceMock->expects($this->once())\n ->method('syncRemotelyDeletedObjects')\n ->with(\\Jiminny\\Enums\\CrmObject::CONTACT)\n ->willThrowException($exception);\n\n // Expect warning to be logged with correct message and parameters\n $logger->expects($this->once())\n ->method('warning')\n ->with(\n '[Salesforce] Remotely deleted objects sync failed',\n [\n 'objectType' => 'contact',\n 'teamId' => 'team-uuid-456',\n 'reason' => 'Sync failed due to API error',\n ]\n );\n\n // Call the protected method using reflection\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');\n $method->setAccessible(true);\n\n // Should not re-throw the exception, just log it\n $method->invoke($serviceMock, \\Jiminny\\Enums\\CrmObject::CONTACT);\n\n $this->assertTrue(true); // Test completed successfully\n }\n\n public function testSyncRemotelyDeletedObjectsWithErrorHandlingWithLogParams(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['syncRemotelyDeletedObjects'])\n ->getMock();\n\n // Mock team\n $team = $this->createMock(Team::class);\n $team->method('getUuid')->willReturn('team-uuid-789');\n $serviceMock->team = $team;\n\n // Mock logger to verify warning is logged\n $logger = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n\n // Use reflection to set the protected logger property\n $loggerReflection = new \\ReflectionClass($serviceMock);\n $loggerProperty = $loggerReflection->getProperty('logger');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($serviceMock, $logger);\n\n $exception = new \\Exception('Network timeout');\n\n // Expect syncRemotelyDeletedObjects to throw an exception\n $serviceMock->expects($this->once())\n ->method('syncRemotelyDeletedObjects')\n ->with(\\Jiminny\\Enums\\CrmObject::OPPORTUNITY)\n ->willThrowException($exception);\n\n // Additional log parameters\n $logParams = [\n 'syncType' => 'full',\n 'batchSize' => 100,\n ];\n\n // Expect warning to be logged with merged parameters\n $logger->expects($this->once())\n ->method('warning')\n ->with(\n '[Salesforce] Remotely deleted objects sync failed',\n [\n 'objectType' => 'opportunity',\n 'teamId' => 'team-uuid-789',\n 'reason' => 'Network timeout',\n 'syncType' => 'full',\n 'batchSize' => 100,\n ]\n );\n\n // Call the protected method using reflection\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');\n $method->setAccessible(true);\n\n // Should not re-throw the exception, just log it\n $method->invoke($serviceMock, \\Jiminny\\Enums\\CrmObject::OPPORTUNITY, $logParams);\n\n $this->assertTrue(true); // Test completed successfully\n }\n\n /**\n * @dataProvider crmObjectProvider\n */\n public function testSyncRemotelyDeletedObjectsWithErrorHandlingDifferentCrmObjects(\\Jiminny\\Enums\\CrmObject $crmObject): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['syncRemotelyDeletedObjects'])\n ->getMock();\n\n // Mock team\n $team = $this->createMock(Team::class);\n $team->method('getUuid')->willReturn('team-uuid-test');\n $serviceMock->team = $team;\n\n // Mock logger to verify warning is logged\n $logger = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n\n // Use reflection to set the protected logger property\n $loggerReflectionClass = new \\ReflectionClass($serviceMock);\n $loggerProperty = $loggerReflectionClass->getProperty('logger');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($serviceMock, $logger);\n\n $exception = new \\Exception('Test error');\n\n // Expect syncRemotelyDeletedObjects to throw an exception\n $serviceMock->expects($this->once())\n ->method('syncRemotelyDeletedObjects')\n ->with($crmObject)\n ->willThrowException($exception);\n\n // Expect warning to be logged with correct entity type\n $logger->expects($this->once())\n ->method('warning')\n ->with(\n '[Salesforce] Remotely deleted objects sync failed',\n [\n 'objectType' => $crmObject->value,\n 'teamId' => 'team-uuid-test',\n 'reason' => 'Test error',\n ]\n );\n\n // Call the protected method using reflection\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');\n $method->setAccessible(true);\n\n $method->invoke($serviceMock, $crmObject);\n\n $this->assertTrue(true); // Test completed successfully\n }\n\n public function testHandleObjectDeletionWithDeletedEntity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['deleteCrmObject'])\n ->getMock();\n\n $entity = $this->createMock(\\Jiminny\\Models\\Account::class);\n $crmData = ['IsDeleted' => true];\n\n $serviceMock->expects($this->once())\n ->method('deleteCrmObject')\n ->with($entity);\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('handleObjectDeletion');\n $method->setAccessible(true);\n\n $method->invoke($serviceMock, $entity, $crmData);\n }\n\n public function testHandleObjectDeletionWithNonDeletedEntity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['deleteCrmObject'])\n ->getMock();\n\n $entity = $this->createMock(\\Jiminny\\Models\\Contact::class);\n $crmData = ['IsDeleted' => false];\n\n $serviceMock->expects($this->never())\n ->method('deleteCrmObject');\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('handleObjectDeletion');\n $method->setAccessible(true);\n\n $method->invoke($serviceMock, $entity, $crmData);\n }\n\n public function testDeleteCrmObjectWithValidEntity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['dispatchDeleteCrmObjectJob'])\n ->getMock();\n\n $entity = $this->createMock(\\Jiminny\\Models\\Lead::class);\n $entity->expects($this->once())->method('delete');\n\n $serviceMock->expects($this->once())\n ->method('dispatchDeleteCrmObjectJob')\n ->with($entity);\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('deleteCrmObject');\n $method->setAccessible(true);\n\n $method->invoke($serviceMock, $entity);\n }\n\n public function testDeleteCrmObjectWithNullEntity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['dispatchDeleteCrmObjectJob'])\n ->getMock();\n\n $serviceMock->expects($this->never())\n ->method('dispatchDeleteCrmObjectJob');\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('deleteCrmObject');\n $method->setAccessible(true);\n\n $method->invoke($serviceMock, null);\n }\n\n public function testDispatchDeleteCrmObjectJobWithNullEntity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($service);\n $method = $reflection->getMethod('dispatchDeleteCrmObjectJob');\n $method->setAccessible(true);\n\n // Should return early without dispatching - no exception expected\n $method->invoke($service, null);\n\n $this->assertTrue(true); // Test completed successfully\n }\n\n public function testDispatchDeleteCrmObjectJobWithUnsupportedEntity(): void\n {\n $this->expectException(\\TypeError::class);\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n $unsupportedEntity = $this->createMock(\\stdClass::class);\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($service);\n $method = $reflection->getMethod('dispatchDeleteCrmObjectJob');\n\n // This will throw TypeError due to union type constraint\n $method->invoke($service, $unsupportedEntity);\n }\n\n public function testHandleEntityDeletionByProviderIdMethodExists(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n // Test that the method exists and is accessible via reflection\n $reflection = new \\ReflectionClass($service);\n $method = $reflection->getMethod('handleEntityDeletionByProviderId');\n $method->setAccessible(true);\n\n // Verify method exists and has correct parameters\n $this->assertTrue($method->isProtected());\n $this->assertEquals(2, $method->getNumberOfParameters());\n\n $parameters = $method->getParameters();\n $this->assertEquals('targetEntity', $parameters[0]->getName());\n $this->assertEquals('crmData', $parameters[1]->getName());\n }\n\n public function testSyncRemotelyDeletedObjectsWithNoResults(): void\n {\n // Create a real service instance to avoid mock property issues\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n // Mock queryHandler to throw NoResultsException\n $queryHandler = $this->createMock(QueryHandler::class);\n $queryHandler->expects($this->once())\n ->method('queryDeleted')\n ->with('Opportunity')\n ->willThrowException(new NoResultsException('No results'));\n\n // Set the queryHandler using reflection on the real service\n $reflection = new \\ReflectionClass($service);\n $queryHandlerProperty = $reflection->getProperty('queryHandler');\n $queryHandlerProperty->setAccessible(true);\n $queryHandlerProperty->setValue($service, $queryHandler);\n\n $result = self::invokePrivateMethod('syncRemotelyDeletedObjects', $service, [CrmObject::OPPORTUNITY]);\n\n $this->assertFalse($result);\n }\n\n public function testSyncRemotelyDeletedObjectsWithEmptyResults(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n // Mock queryHandler to return empty results\n $queryResult = $this->createMock(QueryResults::class);\n $queryResult->method('getResults')->willReturn([]);\n\n $queryHandler = $this->createMock(QueryHandler::class);\n $queryHandler->expects($this->once())\n ->method('queryDeleted')\n ->with('Opportunity')\n ->willReturn($queryResult);\n\n // Set the queryHandler using reflection on the real service\n $reflection = new \\ReflectionClass($service);\n $queryHandlerProperty = $reflection->getProperty('queryHandler');\n $queryHandlerProperty->setAccessible(true);\n $queryHandlerProperty->setValue($service, $queryHandler);\n\n $result = self::invokePrivateMethod('syncRemotelyDeletedObjects', $service, [CrmObject::OPPORTUNITY]);\n\n $this->assertFalse($result);\n }\n\n public function testSyncRemotelyDeletedObjectsWithUnsupportedCrmObject(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n // Mock queryHandler to return some deleted objects so we reach the match statement\n $deletedObjects = [\n ['id' => 'task1'],\n ['id' => 'task2'],\n ];\n $queryResult = $this->createMock(QueryResults::class);\n $queryResult->method('getResults')->willReturn($deletedObjects);\n\n $queryHandler = $this->createMock(QueryHandler::class);\n $queryHandler->expects($this->once())\n ->method('queryDeleted')\n ->with('Task') // ucfirst('task') = 'Task'\n ->willReturn($queryResult);\n\n self::setPrivateProperty($service, 'queryHandler', $queryHandler);\n\n $this->expectException(InvalidArgumentException::class);\n $this->expectExceptionMessage('Unsupported CrmObject: task');\n\n self::invokePrivateMethod('syncRemotelyDeletedObjects', $service, [CrmObject::TASK]);\n }\n\n public static function crmObjectProvider(): array\n {\n return [\n 'Account' => [CrmObject::ACCOUNT],\n 'Contact' => [CrmObject::CONTACT],\n 'Lead' => [CrmObject::LEAD],\n 'Opportunity' => [CrmObject::OPPORTUNITY],\n ];\n }\n\n public function testVerifyTaskExistsReturnsTrue(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-123', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-123');\n $activity->method('getId')->willReturn(456);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-123', ['Id', 'IsDeleted'])\n ->willReturn(['Id' => 'task-123', 'IsDeleted' => false]);\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertTrue($result);\n }\n\n public function testVerifyTaskExistsReturnsTrueForEvent(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:event-123', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('event-123');\n $activity->method('getId')->willReturn(456);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_EVENT);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Event', 'event-123', ['Id', 'IsDeleted'])\n ->willReturn(['Id' => 'event-123', 'IsDeleted' => false]);\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertTrue($result);\n }\n\n public function testVerifyTaskExistsReturnsFalseWhenDeleted(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-456', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-456');\n $activity->method('getId')->willReturn(789);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-456', ['Id', 'IsDeleted'])\n ->willReturn(['Id' => 'task-456', 'IsDeleted' => true]);\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertFalse($result);\n }\n\n public function testVerifyTaskExistsReturnsFalseWhenNotFound(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-999', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-999');\n $activity->method('getId')->willReturn(999);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-999', ['Id', 'IsDeleted'])\n ->willThrowException(new \\Jiminny\\Exceptions\\HttpNotFoundException('Task not found'));\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertFalse($result);\n }\n\n public function testVerifyTaskExistsReturnsFalseWhenNoPlaybook(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-no-playbook', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-no-playbook');\n $activity->method('getId')->willReturn(111);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn(null);\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertFalse($result);\n }\n\n public function testVerifyTaskExistsThrowsExceptionForTransientErrors(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-error', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-error');\n $activity->method('getId')->willReturn(888);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-error', ['Id', 'IsDeleted'])\n ->willThrowException(new \\RuntimeException('Network timeout'));\n\n $this->expectException(\\RuntimeException::class);\n $this->expectExceptionMessage('Network timeout');\n\n $service->verifyTaskExists($activity);\n }\n\n public function testVerifyTaskExistsCachesResults(): void\n {\n $cachedValue = null;\n Cache::shouldReceive('remember')\n ->twice()\n ->with('crm_task_exists:123:task-cached', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(function ($key, $ttl, $callback) use (&$cachedValue) {\n if ($cachedValue === null) {\n $cachedValue = $callback();\n }\n\n return $cachedValue;\n });\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-cached');\n $activity->method('getId')->willReturn(555);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-cached', ['Id', 'IsDeleted'])\n ->willReturn(['Id' => 'task-cached', 'IsDeleted' => false]);\n\n $result1 = $service->verifyTaskExists($activity);\n $result2 = $service->verifyTaskExists($activity);\n\n $this->assertTrue($result1);\n $this->assertTrue($result2);\n }\n\n public function testVerifyTaskExistsReturnsFalseForHttpBadRequestException(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-400', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-400');\n $activity->method('getId')->willReturn(400);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-400', ['Id', 'IsDeleted'])\n ->willThrowException(new \\Jiminny\\Exceptions\\HttpBadRequestException('Bad request'));\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertFalse($result);\n }\n\n public function testImportOpportunitySkipsWhenNoProfileAndNoAccount(): void\n {\n $crmData = [\n 'Id' => 'SF-NO-USER-1',\n 'Name' => 'Test Opportunity',\n 'OwnerId' => 'owner-no-profile',\n // No AccountId\n ];\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(\\Illuminate\\Events\\Dispatcher::class); // ← ADD THIS\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n\n $service = new Service(\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService\n );\n\n $config = $this->createMock(Configuration::class);\n\n // Mock profiles relation returning null (no profile found)\n $profilesRelation = \\Mockery::mock(\\Illuminate\\Database\\Eloquent\\Relations\\HasMany::class);\n $profilesRelation->shouldReceive('where')->with('crm_provider_id', 'owner-no-profile')->andReturnSelf();\n $profilesRelation->shouldReceive('first')->andReturn(null);\n\n $config->expects($this->once())\n ->method('profiles')\n ->willReturn($profilesRelation);\n\n $team = $this->createMock(Team::class);\n $team->method('getId')->willReturn(1);\n\n $logger = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n $logger->expects($this->once())\n ->method('error')\n ->with(\n '[Salesforce] | Skip import, no user_id found',\n ['id' => 'SF-NO-USER-1']\n );\n\n $reflection = new \\ReflectionClass($service);\n\n $configProperty = $reflection->getProperty('config');\n $configProperty->setAccessible(true);\n $configProperty->setValue($service, $config);\n\n $teamProperty = $reflection->getProperty('team');\n $teamProperty->setAccessible(true);\n $teamProperty->setValue($service, $team);\n\n $loggerProperty = $reflection->getProperty('logger');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($service, $logger);\n\n // Initialize profile property to avoid \"must not be accessed before initialization\" error\n $profileProperty = $reflection->getProperty('profile');\n $profileProperty->setAccessible(true);\n $profileProperty->setValue($service, null);\n\n $result = self::invokePrivateMethod('importOpportunity', $service, [$crmData]);\n\n $this->assertNull($result);\n }\n\n public function testImportOpportunitySkipsWhenProfileNotFound(): void\n {\n $crmData = [\n 'Id' => 'SF-NO-USER-2',\n 'Name' => 'Test Opportunity',\n 'OwnerId' => 'owner-not-found',\n // No AccountId - avoid complex account processing\n ];\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $eventDispatcher = $this->createMock(\\Illuminate\\Events\\Dispatcher::class); // ← ADD THIS\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n\n\n $service = new Service(\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService\n );\n\n $config = $this->createMock(Configuration::class);\n\n // Mock profiles relation returning null (no profile found)\n $profilesRelation = \\Mockery::mock(\\Illuminate\\Database\\Eloquent\\Relations\\HasMany::class);\n $profilesRelation->shouldReceive('where')->with('crm_provider_id', 'owner-not-found')->andReturnSelf();\n $profilesRelation->shouldReceive('first')->andReturn(null);\n\n $config->expects($this->once())\n ->method('profiles')\n ->willReturn($profilesRelation);\n\n $team = $this->createMock(Team::class);\n $team->method('getId')->willReturn(1);\n\n $logger = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n $logger->expects($this->once())\n ->method('error')\n ->with(\n '[Salesforce] | Skip import, no user_id found',\n ['id' => 'SF-NO-USER-2']\n );\n\n $reflection = new \\ReflectionClass($service);\n\n $configProperty = $reflection->getProperty('config');\n $configProperty->setAccessible(true);\n $configProperty->setValue($service, $config);\n\n $teamProperty = $reflection->getProperty('team');\n $teamProperty->setAccessible(true);\n $teamProperty->setValue($service, $team);\n\n $loggerProperty = $reflection->getProperty('logger');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($service, $logger);\n\n // Initialize profile property\n $profileProperty = $reflection->getProperty('profile');\n $profileProperty->setAccessible(true);\n $profileProperty->setValue($service, null);\n\n $result = self::invokePrivateMethod('importOpportunity', $service, [$crmData]);\n\n $this->assertNull($result);\n }\n}\n\nclass HasManyExtended extends HasMany\n{\n public function where()\n {\n }\n\n public function first()\n {\n }\n}","depth":4,"bounds":{"left":0.16223404,"top":0.09736632,"width":0.3550532,"height":0.90263367},"on_screen":true,"value":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Services\\Crm\\Salesforce;\n\nuse Illuminate\\Database\\Eloquent\\Relations\\HasMany;\nuse Illuminate\\Events\\Dispatcher;\nuse Illuminate\\Support\\Facades\\Cache;\nuse Jiminny\\Component\\Country\\CountriesMap;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Enums\\CrmObject;\nuse Jiminny\\Exceptions\\InvalidArgumentException;\nuse Jiminny\\Exceptions\\NoResultsException;\nuse Jiminny\\Integrations\\PlaybookResolver;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Crm\\Configuration;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\TeamSettings;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\ContactRoleRepository;\nuse Jiminny\\Repositories\\Crm\\FieldDataRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\Salesforce\\Client;\nuse Jiminny\\Services\\Crm\\Salesforce\\PayloadBuilder;\nuse Jiminny\\Services\\Crm\\Salesforce\\QueryBuilder;\nuse Jiminny\\Services\\Crm\\Salesforce\\QueryHandler;\nuse Jiminny\\Services\\Crm\\Salesforce\\QueryIterator;\nuse Jiminny\\Services\\Crm\\Salesforce\\QueryResults;\nuse Jiminny\\Services\\Crm\\Salesforce\\Service;\nuse PHPUnit\\Framework\\MockObject\\MockObject;\nuse Tests\\TestCase;\nuse Tests\\Unit\\Traits\\TestPrivateMethod;\n\nclass ServiceTest extends TestCase\n{\n use TestPrivateMethod;\n\n public function testFetchAndAssociateRelatedActivity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $payloadBuilder->method('addCustomLogicFieldsPayload')\n ->willReturnCallback(function ($activity, $payload) {\n return $payload;\n });\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods(['fetchRelatedActivity', 'getPlaybook', 'getPlaybookCategory', 'updateRecord'])\n ->getMock();\n\n $serviceMock->expects($this->once())\n ->method('fetchRelatedActivity')\n ->willReturn([\n 'Id' => 'testId',\n 'Type' => null,\n 'OwnerId' => 'testerUser',\n 'Description' => 'Test description',\n ]);\n\n $user = $this->createMock(User::class);\n $team = $this->createMock(Team::class);\n $user->method('getAttribute')->with('team')->willReturn($team);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityField')->willReturn(null);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_EVENT);\n\n $serviceMock->expects($this->once())\n ->method('getPlaybook')\n ->with($user)\n ->willReturn($playbook);\n\n $serviceMock->expects($this->never())\n ->method('getPlaybookCategory');\n\n $serviceMock->expects($this->never())\n ->method('updateRecord');\n\n $fieldDataRepository = $this->createMock(FieldDataRepository::class);\n $fieldDataRepository->method('getActivityFieldData')->willReturn(collect([]));\n app()->instance(FieldDataRepository::class, $fieldDataRepository);\n\n $config = $this->createMock(Configuration::class);\n $profilesRelation = $this->getMockBuilder(\\Illuminate\\Database\\Eloquent\\Relations\\HasMany::class)\n ->disableOriginalConstructor()\n ->onlyMethods(['get'])\n ->addMethods(['where'])\n ->getMock();\n $profilesRelation->method('where')->willReturnSelf();\n $profilesRelation->method('get')->willReturn(collect([]));\n $config->method('profiles')->willReturn($profilesRelation);\n\n $serviceMock->config = $config;\n $serviceMock->profile = null;\n\n $actualStartTime = \\Carbon\\Carbon::now();\n\n $activity = $this->getMockBuilder(Activity::class)\n ->disableOriginalConstructor()\n ->onlyMethods(['update', 'hasProspect'])\n ->getMock();\n\n $activity->method('update')->willReturn(true);\n $activity->method('hasProspect')->willReturn(true);\n\n $activity->type = Activity::TYPE_CONFERENCE;\n $activity->provider = Activity::PROVIDER_TWILIO;\n $activity->lead_id = 1;\n $activity->user_id = 0;\n $activity->id_string = 'test-activity-id';\n $activity->user = $user;\n\n $activity->actual_start_time = $actualStartTime;\n $activity->uuid = 'c53d8320-f556-4cee-a2f8-5f232f454ca4';\n\n app()->bind(PlaybookResolver::class, function () use ($user) {\n $playbook = $this->createMock(Playbook::class);\n $playbookResolver = $this->createMock(PlaybookResolver::class);\n $playbookResolver->expects($this->once())\n ->method('resolvePlaybookByUser')\n ->with($user)\n ->willReturn($playbook);\n\n return $playbookResolver;\n });\n\n $data = $serviceMock->fetchAndAssociateRelatedActivity($activity);\n\n $this->assertInstanceOf(Activity::class, $data);\n $this->assertEquals(Activity::TYPE_CONFERENCE, $data->getType());\n $this->assertEquals($actualStartTime->getTimestamp(), $data->getActualStartTime()->getTimestamp());\n }\n\n public function testFetchAndAssociateRelatedActivitySkipsForTaskBasedPlaybook(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods(['fetchRelatedActivity', 'getPlaybook'])\n ->getMock();\n\n $user = $this->createMock(User::class);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n $playbook->method('getId')->willReturn(123);\n\n $serviceMock->expects($this->once())\n ->method('getPlaybook')\n ->with($user)\n ->willReturn($playbook);\n\n $serviceMock->expects($this->never())\n ->method('fetchRelatedActivity');\n\n $activity = $this->getMockBuilder(Activity::class)\n ->disableOriginalConstructor()\n ->onlyMethods(['hasProspect', 'getUuid'])\n ->getMock();\n\n $activity->method('hasProspect')->willReturn(true);\n $activity->method('getUuid')->willReturn('c53d8320-f556-4cee-a2f8-5f232f454ca4');\n $activity->type = Activity::TYPE_CONFERENCE;\n $activity->actual_start_time = \\Carbon\\Carbon::now();\n $activity->user = $user;\n\n $result = $serviceMock->fetchAndAssociateRelatedActivity($activity);\n\n $this->assertNull($result);\n }\n\n public function testFetchAndAssociateRelatedActivityReturnsNullForNonConference(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $serviceMock = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)\n );\n\n $activity = $this->getMockBuilder(Activity::class)\n ->disableOriginalConstructor()\n ->getMock();\n\n $activity->type = Activity::TYPE_SOFTPHONE;\n\n $result = $serviceMock->fetchAndAssociateRelatedActivity($activity);\n\n $this->assertNull($result);\n }\n\n public function testFetchAndAssociateRelatedActivityReturnsNullWhenNoStartTime(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $serviceMock = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)\n );\n\n\n $activity = $this->getMockBuilder(Activity::class)\n ->disableOriginalConstructor()\n ->getMock();\n\n $activity->type = Activity::TYPE_CONFERENCE;\n $activity->actual_start_time = null;\n $activity->scheduled_start_time = null;\n\n $result = $serviceMock->fetchAndAssociateRelatedActivity($activity);\n\n $this->assertNull($result);\n }\n\n public function testFetchAndAssociateRelatedActivityReturnsNullWhenNoProspect(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods(['getPlaybook'])\n ->getMock();\n\n $serviceMock->expects($this->never())\n ->method('getPlaybook');\n\n $activity = $this->getMockBuilder(Activity::class)\n ->disableOriginalConstructor()\n ->onlyMethods(['hasProspect', 'getUuid'])\n ->getMock();\n\n $activity->method('hasProspect')->willReturn(false);\n $activity->method('getUuid')->willReturn('c53d8320-f556-4cee-a2f8-5f232f454ca4');\n $activity->type = Activity::TYPE_CONFERENCE;\n $activity->actual_start_time = \\Carbon\\Carbon::now();\n\n $result = $serviceMock->fetchAndAssociateRelatedActivity($activity);\n\n $this->assertNull($result);\n }\n\n public function testMatchExactlyByEmail(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods([])\n ->getMock();\n\n $profile = new Profile();\n $profile->setAttribute('id', bin2hex(random_bytes(8)));\n $serviceMock->profile = $profile;\n\n $team = $this->createMock(Team::class);\n $serviceMock->team = $team;\n\n $data = $serviceMock->matchExactlyByEmail(bin2hex(random_bytes(8)) . 'test_email@testserver.com');\n\n $this->assertEquals(null, $data);\n }\n\n public function testMatchDomainFromEmail(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $queryIterator = $this->createMock(QueryIterator::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $config = $this->createMock(Configuration::class);\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('search')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods(['convertCrmData'])\n ->getMock();\n\n $profile = new Profile();\n $profile->account_fields = 'Field1, Field2, Field3';\n $serviceMock->profile = $profile;\n\n $serviceMock->expects($this->once())\n ->method('convertCrmData')\n ->willReturn(['test']);\n\n $this->app->bind(QueryBuilder::class, function () {\n $queryBuilder = $this->createMock(QueryBuilder::class);\n $queryBuilder->expects($this->once())\n ->method('buildMatchByDomainQuery')\n ->with('test_email@testserver.com')\n ->willReturn('FIND {test_email@testserver.com} IN ALL FIELDS RETURNING Account(Id)');\n\n return $queryBuilder;\n });\n\n $team = $this->createMock(Team::class);\n\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n\n $data = $serviceMock->matchByDomain('test_email@testserver.com');\n\n $this->assertEquals(['test'], $data);\n }\n\n public function testBuildTaskSearchFields(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)\n );\n\n $fields = $service->buildTaskSearchFields();\n\n $expectedFields = ['Id', 'WhoId', 'WhatId', 'AccountId'];\n\n $this->assertEquals($expectedFields, $fields);\n }\n\n public function testMapCrmObjects(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class),\n );\n\n $sampleTask = [\n 'WhoId' => '003sampleWhoId',\n 'AccountId' => 'sampleAccountId',\n 'WhatId' => 'sampleWhatId',\n ];\n\n $activityData = $service->mapCrmObjects($sampleTask);\n\n $expectedActivityData = [\n 'contact' => '003sampleWhoId',\n 'account' => 'sampleAccountId',\n 'opportunity' => 'sampleWhatId',\n ];\n\n $this->assertEquals($expectedActivityData, $activityData);\n }\n\n public function testGetInstalledAppVersion(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n $queryIterator = $this->createMock(QueryIterator::class);\n $queryIterator->expects($this->any())\n ->method('current')->willReturn([\n 'SubscriberPackageVersion' => [\n 'MajorVersion' => '1',\n 'MinorVersion' => '0',\n 'PatchVersion' => '1',\n 'BuildNumber' => '0',\n ],\n ]);\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('metadata')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods(array_diff(get_class_methods(Service::class), ['getInstalledAppVersion']))\n ->getMock();\n\n $version = $serviceMock->getInstalledAppVersion();\n\n $this->assertEquals('1010', $version);\n }\n\n public function testSyncProfiles(): void\n {\n $userToSearch = null;\n\n $team = $this->createMock(Team::class);\n $config = $this->createMockedConfiguration();\n $config->expects($this->once())\n ->method('getId')\n ->willReturn(1);\n\n $salesforceUser = [\n 'Email' => 'test@example.com',\n 'UserPreferencesLightningExperiencePreferred' => true,\n 'CallCenterId' => '123',\n 'Id' => '456',\n 'ProfileId' => '789',\n ];\n\n app()->bind(QueryBuilder::class, function () use ($userToSearch) {\n $queryBuilder = $this->createMock(QueryBuilder::class);\n $queryBuilder->expects($this->once())\n ->method('buildGetUsersQuery')\n ->with($userToSearch)\n ->willReturn('SELECT * FROM Users');\n\n return $queryBuilder;\n });\n\n $queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('query')\n ->with('SELECT * FROM Users')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n\n $user = $this->createMock(User::class);\n $user->expects($this->once())\n ->method('getId')\n ->willReturn(123);\n\n $this->mockTeamRepository($team, $salesforceUser, $user);\n\n $profileRepository = $this->createMock(ProfileRepository::class);\n $profileRepository->expects($this->once())\n ->method('updateOrCreateProfile')\n ->with(\n $user,\n [\n 'crm_configuration_id' => 1,\n 'crm_provider_id' => '456',\n ],\n [\n 'user_id' => 123,\n 'edition' => Profile::EDITION_LIGHTNING,\n 'has_external_cti' => true,\n 'crm_profile_id' => '789',\n ]\n )\n ->willReturn(new Profile());\n\n $this->app->instance(ProfileRepository::class, $profileRepository);\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n $result = $serviceMock->syncProfiles($userToSearch);\n\n $this->assertNull($result);\n }\n\n public function testSyncProfilesEmailIsNull(): void\n {\n $userToSearch = $this->createMock(User::class);\n\n $salesforceUser = [\n 'Email' => null,\n ];\n\n app()->bind(QueryBuilder::class, function () {\n $queryBuilder = $this->createMock(QueryBuilder::class);\n $queryBuilder->expects($this->once())\n ->method('buildGetUsersQuery')\n ->with(null)\n ->willReturn('SELECT * FROM Users');\n\n return $queryBuilder;\n });\n\n $queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->exactly(2))\n ->method('query')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n\n $team = $this->createMock(Team::class);\n $user = $this->createMock(User::class);\n $this->mockTeamRepository($team, $salesforceUser, $user, false);\n\n $config = $this->createMock(Configuration::class);\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n\n $profile = $serviceMock->syncProfiles(null);\n\n $this->assertNull($profile);\n }\n\n public function testSyncProfilesUserToSearchMatchesCurrentUser(): void\n {\n $userToSearch = $this->createMock(User::class);\n $userToSearch->expects($this->once())\n ->method('getId')\n ->willReturn(123);\n\n $team = $this->createMock(Team::class);\n $config = $this->createMock(Configuration::class);\n $config->expects($this->once())\n ->method('getId')\n ->willReturn(1);\n\n $salesforceUser = [\n 'Email' => 'test@example.com',\n 'UserPreferencesLightningExperiencePreferred' => true,\n 'CallCenterId' => '123',\n 'Id' => '456',\n 'ProfileId' => '789',\n ];\n\n app()->bind(QueryBuilder::class, function () use ($userToSearch) {\n $queryBuilder = $this->createMock(QueryBuilder::class);\n $queryBuilder->expects($this->once())\n ->method('buildGetUsersQuery')\n ->with($userToSearch)\n ->willReturn('SELECT * FROM Users');\n\n return $queryBuilder;\n });\n\n $queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('query')\n ->with('SELECT * FROM Users')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n\n $user = $this->createMock(User::class);\n $user->expects($this->exactly(2))\n ->method('getId')\n ->willReturn(123);\n\n $this->mockTeamRepository($team, $salesforceUser, $user);\n\n $profileRepository = $this->createMock(ProfileRepository::class);\n $profileRepository->expects($this->once())\n ->method('updateOrCreateProfile')\n ->with(\n $user,\n [\n 'crm_configuration_id' => 1,\n 'crm_provider_id' => '456',\n ],\n [\n 'user_id' => 123,\n 'edition' => Profile::EDITION_LIGHTNING,\n 'has_external_cti' => true,\n 'crm_profile_id' => '789',\n ]\n )\n ->willReturn(new Profile());\n\n $this->app->instance(ProfileRepository::class, $profileRepository);\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n $profile = $serviceMock->syncProfiles($userToSearch);\n\n $this->assertInstanceOf(Profile::class, $profile);\n }\n\n public function testSyncProfilesWithCustomValidation(): void\n {\n $userToSearch = null;\n\n $team = $this->createMock(Team::class);\n $config = $this->createMockedConfiguration();\n $config->expects($this->atLeastOnce()) // Changed from once() to atLeastOnce()\n ->method('getId')\n ->willReturn(1);\n\n $salesforceUser = [\n 'Email' => 'test@example.com',\n 'UserPreferencesLightningExperiencePreferred' => true,\n 'CallCenterId' => '123',\n 'Id' => '456',\n 'ProfileId' => '789',\n 'CustomField' => 'CustomValue',\n ];\n\n $customRules = [\n ['field' => 'CustomField', 'value' => 'CustomValue'],\n ];\n\n $this->mockQueryBuilderAndHandler($userToSearch, [$salesforceUser]);\n\n $user = $this->createMock(User::class);\n $user->expects($this->once())\n ->method('getId')\n ->willReturn(123);\n\n $this->mockTeamRepository($team, $salesforceUser, $user, true, $customRules);\n\n $this->mockProfileRepository($user);\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n $profile = $serviceMock->syncProfiles($userToSearch);\n\n $this->assertNull($profile);\n }\n\n public function testSyncProfilesWithCustomValidationFailing(): void\n {\n $userToSearch = null;\n\n $team = $this->createMock(Team::class);\n $config = $this->createMockedConfiguration();\n\n $salesforceUser = [\n 'Email' => 'test@example.com',\n 'UserPreferencesLightningExperiencePreferred' => true,\n 'CallCenterId' => '123',\n 'Id' => '456',\n 'ProfileId' => '789',\n 'CustomField' => 'WrongValue',\n ];\n\n $customRules = [\n ['field' => 'CustomField', 'value' => 'CustomValue'],\n ];\n\n $this->mockQueryBuilderAndHandler($userToSearch, [$salesforceUser]);\n\n $teamRepository = $this->getMockForAbstractClass(TeamRepository::class, [], '', false, true, true, ['findActiveTeamMemberByEmail', 'getTeamSetting']);\n\n $teamSettings = $this->createMock(TeamSettings::class);\n $teamSettings->method('getValueType')\n ->willReturn('array');\n\n $teamSettings->method('getValue')\n ->willReturn(json_encode($customRules));\n\n $teamRepository->expects($this->once())\n ->method('getTeamSetting')\n ->with($team, 'custom_profile_validation')\n ->willReturn($teamSettings);\n\n app()->bind(TeamRepository::class, function () use ($teamRepository) {\n return $teamRepository;\n });\n\n $profileRepository = $this->createMock(ProfileRepository::class);\n $profileRepository->expects($this->never())\n ->method('updateOrCreateProfile');\n\n $this->app->instance(ProfileRepository::class, $profileRepository);\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n $result = $serviceMock->syncProfiles($userToSearch);\n\n $this->assertNull($result);\n }\n\n public function testGetContactRolesFromCrm(): void\n {\n $contactRoles = [\n [\n 'Id' => '1',\n 'ContactId' => 'Contact1',\n 'OpportunityId' => 'Opportunity1',\n 'Opportunity' => ['OwnerId' => 'Owner1'],\n 'IsPrimary' => true,\n 'Role' => 'Decision Maker',\n ],\n ];\n\n $expectedResponse = [\n [\n 'id' => '1',\n 'contactId' => 'Contact1',\n 'opportunityId' => 'Opportunity1',\n 'ownerId' => 'Owner1',\n 'isPrimary' => true,\n 'role' => 'Decision Maker',\n ],\n ];\n\n $this->bindQueryIterator($contactRoles);\n\n $serviceMock = $this->getServiceMock();\n\n $data = $serviceMock->getContactRolesFromCrm(now()->subDay());\n\n $this->assertEquals($expectedResponse, $data);\n }\n\n public function testGetContactRolesFromCrmNoResult(): void\n {\n $this->bindQueryIterator([]);\n\n $serviceMock = $this->getServiceMock();\n\n $data = $serviceMock->getContactRolesFromCrm(now()->subDay());\n\n $this->assertEquals([], $data);\n }\n\n public function testSyncContactRoles(): void\n {\n $contactRoles = [\n [\n 'id' => '1',\n 'contactId' => 'Contact1',\n 'opportunityId' => 'Opportunity1',\n 'ownerId' => 'Owner1',\n 'isPrimary' => true,\n 'role' => 'Decision Maker',\n ],\n ];\n\n app()->bind(ContactRoleRepository::class, function () {\n $contactRoleRepository = $this->createMock(ContactRoleRepository::class);\n $contactRoleRepository->expects($this->once())\n ->method('saveContactRoles');\n\n return $contactRoleRepository;\n });\n\n $serviceMock = $this->getServiceMock([\n 'getContactRolesFromCrm',\n 'syncRemotelyDeletedContactRoles',\n 'syncContact',\n 'syncOpportunity',\n ]);\n\n $config = $this->createMock(Configuration::class);\n $hasMany = $this->createMock(HasManyExtended::class);\n $hasMany->expects($this->exactly(2))\n ->method('where')\n ->willReturn($hasMany);\n\n $hasMany->expects($this->exactly(2))\n ->method('first')\n ->willReturn(\n $this->createMock(Contact::class),\n $this->createMock(Opportunity::class)\n );\n\n $config->expects($this->once())\n ->method('contacts')\n ->willReturn($hasMany);\n $config->expects($this->once())\n ->method('opportunities')\n ->willReturn($hasMany);\n\n $serviceMock->config = $config;\n\n $serviceMock->expects($this->once())\n ->method('getContactRolesFromCrm')\n ->willReturn($contactRoles);\n\n $serviceMock->expects($this->once())\n ->method('syncRemotelyDeletedContactRoles');\n\n $data = $serviceMock->syncContactRoles(now()->subDay());\n\n $this->assertEquals(1, $data);\n }\n\n public function testSyncRemotelyDeletedContactRoles(): void\n {\n $contactRoles = [\n [\n 'id' => '1',\n 'crm_provider_id' => '1',\n ],\n ];\n\n app()->bind(QueryHandler::class, function () use ($contactRoles) {\n $queryResults = new QueryResults($contactRoles, 1, true, null);\n\n $handler = $this->createMock(QueryHandler::class);\n $handler->method('queryDeleted')\n ->willReturn($queryResults);\n\n return $handler;\n });\n\n app()->bind(ContactRoleRepository::class, function () {\n $contactRoleRepository = $this->createMock(ContactRoleRepository::class);\n $contactRoleRepository->expects($this->once())\n ->method('deleteContactRoles');\n\n return $contactRoleRepository;\n });\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $this->createMock(Team::class);\n\n $data = $this->invokePrivateMethod('syncRemotelyDeletedContactRoles', $serviceMock, []);\n\n $this->assertTrue($data);\n }\n\n private function bindQueryIterator(array $queryResult): void\n {\n /** @var Client $client */\n $client = $this->createMock(Client::class);\n $queryIterator = new QueryIterator(\n $client,\n new QueryResults($queryResult, 1, true, null)\n );\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('query')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n }\n\n public static function getOpportunitySortOrderDataProvider(): array\n {\n return [\n 'all open recently updated' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED, ['LastModifiedDate DESC', true]],\n 'all open recently created' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED, ['CreatedDate DESC', true]],\n 'all open oldest created' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED, ['CreatedDate ASC', true]],\n 'all recently updated' => [Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED, ['LastModifiedDate DESC', false]],\n 'default' => ['unknown', ['LastModifiedDate DESC', true]],\n ];\n }\n\n private function createMockedConfiguration(): Configuration\n {\n $config = $this->createMock(Configuration::class);\n $profilesRelation = $this->getMockBuilder(\\Illuminate\\Database\\Eloquent\\Relations\\HasMany::class)\n ->disableOriginalConstructor()\n ->onlyMethods(['get'])\n ->addMethods(['where', 'first'])\n ->getMock();\n $profilesRelation->method('where')->willReturnSelf();\n $profilesRelation->method('get')->willReturn(collect([]));\n $profilesRelation->method('first')->willReturn(null);\n $config->method('profiles')->willReturn($profilesRelation);\n\n return $config;\n }\n\n private function getServiceMock(array $onlyMethods = []): MockObject&Service\n {\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $this->createMock(Client::class),\n $this->createMock(PayloadBuilder::class),\n $this->createMock(Dispatcher::class),\n $this->createMock(CountriesMap::class),\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods($onlyMethods)\n ->getMock();\n\n $serviceMock->profile = $this->createMock(Profile::class);\n\n return $serviceMock;\n }\n\n private function mockQueryBuilderAndHandler($userToSearch, $salesforceUsers): void\n {\n app()->bind(QueryBuilder::class, function () use ($userToSearch) {\n $queryBuilder = $this->createMock(QueryBuilder::class);\n $queryBuilder->expects($this->once())\n ->method('buildGetUsersQuery')\n ->with($userToSearch)\n ->willReturn('SELECT * FROM Users');\n\n return $queryBuilder;\n });\n\n $queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults($salesforceUsers, count($salesforceUsers), true, null));\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('query')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n }\n\n private function mockTeamRepository(\n Team $team,\n array $salesforceUser,\n ?User $user = null,\n bool $userSearch = true,\n array $customRules = []\n ): void {\n $teamRepository = $this->getMockForAbstractClass(TeamRepository::class, [], '', false, true, true, ['findActiveTeamMemberByEmail', 'getTeamSetting']);\n\n if ($userSearch) {\n $teamRepository->expects($this->once())\n ->method('findActiveTeamMemberByEmail')\n ->with($team, $salesforceUser['Email'])\n ->willReturn($user);\n }\n\n $teamSettings = $this->createMock(TeamSettings::class);\n $teamSettings->method('getValueType')\n ->willReturn('array');\n\n $teamSettings->method('getValue')\n ->willReturn(json_encode($customRules));\n\n $teamRepository->expects($this->once())\n ->method('getTeamSetting')\n ->with($team, 'custom_profile_validation')\n ->willReturn($teamSettings);\n\n app()->bind(TeamRepository::class, function () use ($teamRepository) {\n return $teamRepository;\n });\n }\n\n private function mockProfileRepository(User $user): void\n {\n $profileRepository = $this->createMock(ProfileRepository::class);\n $profileRepository->expects($this->once())\n ->method('updateOrCreateProfile')\n ->with(\n $user,\n [\n 'crm_configuration_id' => 1,\n 'crm_provider_id' => '456',\n ],\n [\n 'user_id' => 123,\n 'edition' => Profile::EDITION_LIGHTNING,\n 'has_external_cti' => true,\n 'crm_profile_id' => '789',\n ]\n )\n ->willReturn(new Profile());\n\n $this->app->instance(ProfileRepository::class, $profileRepository);\n }\n\n public function testBuildEnhancedNoteDecodesHtmlEntities(): void\n {\n $service = $this->getServiceMock(['createRecord']);\n\n $profile = new Profile();\n $profile->setAttribute('crm_provider_id', 'owner-123');\n $service->profile = $profile;\n\n $service->expects($this->exactly(2))\n ->method('createRecord')\n ->willReturnOnConsecutiveCalls('note-id-123', 'link-id-456');\n\n $bodyWithEntities = 'Welch's current challenges and Facebook's Club';\n\n $result = $this->invokePrivateMethod('buildEnhancedNote', $service, [\n 'Test Title',\n $bodyWithEntities,\n 'object-id-789',\n ]);\n\n $this->assertEquals('note-id-123', $result);\n }\n\n public function testBuildEnhancedNoteSanitizesWithoutQuotes(): void\n {\n $service = $this->getServiceMock(['createRecord']);\n\n $profile = new Profile();\n $profile->setAttribute('crm_provider_id', 'owner-456');\n $service->profile = $profile;\n\n $service->expects($this->exactly(2))\n ->method('createRecord')\n ->willReturnCallback(function ($type, $data) {\n if ($type === 'ContentNote') {\n $decoded = base64_decode($data['Content']);\n $this->assertStringContainsString(\"Welch's\", $decoded);\n $this->assertStringNotContainsString(''', $decoded);\n $this->assertStringNotContainsString('&#039;', $decoded);\n $this->assertStringContainsString('<script>', $decoded);\n\n return 'note-id-456';\n }\n\n return 'link-id-789';\n });\n\n $bodyWithMixedContent = \"Welch's and <script>alert('xss')</script>\";\n\n $result = $this->invokePrivateMethod('buildEnhancedNote', $service, [\n 'Test Title',\n $bodyWithMixedContent,\n 'object-id-123',\n ]);\n\n $this->assertEquals('note-id-456', $result);\n }\n\n public function testBuildEnhancedNoteConvertsLineBreaks(): void\n {\n $service = $this->getServiceMock(['createRecord']);\n\n $profile = new Profile();\n $profile->setAttribute('crm_provider_id', 'owner-789');\n $service->profile = $profile;\n\n $service->expects($this->exactly(2))\n ->method('createRecord')\n ->willReturnCallback(function ($type, $data) {\n if ($type === 'ContentNote') {\n $decoded = base64_decode($data['Content']);\n $this->assertStringContainsString('<br>', $decoded);\n $this->assertStringNotContainsString('<br />', $decoded);\n\n return 'note-id-789';\n }\n\n return 'link-id-012';\n });\n\n $bodyWithLineBreaks = \"Line 1\\nLine 2\\nLine 3\";\n\n $result = $this->invokePrivateMethod('buildEnhancedNote', $service, [\n 'Test Title',\n $bodyWithLineBreaks,\n 'object-id-456',\n ]);\n\n $this->assertEquals('note-id-789', $result);\n }\n\n public function testBuildEnhancedNoteHandlesComplexScenario(): void\n {\n $service = $this->getServiceMock(['createRecord']);\n\n $profile = new Profile();\n $profile->setAttribute('crm_provider_id', 'owner-complex');\n $service->profile = $profile;\n\n $service->expects($this->exactly(2))\n ->method('createRecord')\n ->willReturnCallback(function ($type, $data) {\n if ($type === 'ContentNote') {\n $decoded = base64_decode($data['Content']);\n\n $this->assertStringContainsString(\"Welch's\", $decoded);\n $this->assertStringContainsString(\"Facebook's Club\", $decoded);\n $this->assertStringContainsString(\"Arctics'\", $decoded);\n $this->assertStringNotContainsString(''', $decoded);\n $this->assertStringNotContainsString('&#039;', $decoded);\n $this->assertStringContainsString('<br>', $decoded);\n $this->assertStringContainsString('<', $decoded);\n $this->assertStringContainsString('>', $decoded);\n\n return 'note-complex';\n }\n\n return 'link-complex';\n });\n\n $complexBody = \"Summary:\\n---------\\nThe call focused on understanding Welch's current challenges and exploring how Arctics' Revenue Growth Management solutions could support their strategic goals.\\n\\n• John SMith discussed his role as a category advisor for Google and Facebook's Club, emphasizing the importance of market research and advising on product assortment.\\n• Madona introduced Arctics' Virtual Shoppers AI, which simulates consumer <behavior> to optimize pricing and promotional strategies.\";\n\n $result = $this->invokePrivateMethod('buildEnhancedNote', $service, [\n 'Jiminny Transcription Summary',\n $complexBody,\n 'task-id-001',\n ]);\n\n $this->assertEquals('note-complex', $result);\n }\n\n public function testSyncRemotelyDeletedObjectsWithErrorHandlingSuccess(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['syncRemotelyDeletedObjects'])\n ->getMock();\n\n // Mock team\n $team = $this->createMock(Team::class);\n $team->method('getUuid')->willReturn('team-uuid-123');\n $serviceMock->team = $team;\n\n // Expect syncRemotelyDeletedObjects to be called once and succeed\n $serviceMock->expects($this->once())\n ->method('syncRemotelyDeletedObjects')\n ->with(\\Jiminny\\Enums\\CrmObject::ACCOUNT);\n\n // Call the protected method using reflection\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');\n $method->setAccessible(true);\n\n // Should not throw any exceptions\n $method->invoke($serviceMock, \\Jiminny\\Enums\\CrmObject::ACCOUNT);\n\n $this->assertTrue(true); // Test completed successfully\n }\n\n public function testSyncRemotelyDeletedObjectsWithErrorHandlingFailure(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['syncRemotelyDeletedObjects'])\n ->getMock();\n\n // Mock team\n $team = $this->createMock(Team::class);\n $team->method('getUuid')->willReturn('team-uuid-456');\n $serviceMock->team = $team;\n\n // Mock logger to verify warning is logged\n $logger = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n\n // Use reflection to set the protected logger property\n $reflection = new \\ReflectionClass($serviceMock);\n $loggerProperty = $reflection->getProperty('logger');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($serviceMock, $logger);\n\n $exception = new \\Exception('Sync failed due to API error');\n\n // Expect syncRemotelyDeletedObjects to throw an exception\n $serviceMock->expects($this->once())\n ->method('syncRemotelyDeletedObjects')\n ->with(\\Jiminny\\Enums\\CrmObject::CONTACT)\n ->willThrowException($exception);\n\n // Expect warning to be logged with correct message and parameters\n $logger->expects($this->once())\n ->method('warning')\n ->with(\n '[Salesforce] Remotely deleted objects sync failed',\n [\n 'objectType' => 'contact',\n 'teamId' => 'team-uuid-456',\n 'reason' => 'Sync failed due to API error',\n ]\n );\n\n // Call the protected method using reflection\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');\n $method->setAccessible(true);\n\n // Should not re-throw the exception, just log it\n $method->invoke($serviceMock, \\Jiminny\\Enums\\CrmObject::CONTACT);\n\n $this->assertTrue(true); // Test completed successfully\n }\n\n public function testSyncRemotelyDeletedObjectsWithErrorHandlingWithLogParams(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['syncRemotelyDeletedObjects'])\n ->getMock();\n\n // Mock team\n $team = $this->createMock(Team::class);\n $team->method('getUuid')->willReturn('team-uuid-789');\n $serviceMock->team = $team;\n\n // Mock logger to verify warning is logged\n $logger = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n\n // Use reflection to set the protected logger property\n $loggerReflection = new \\ReflectionClass($serviceMock);\n $loggerProperty = $loggerReflection->getProperty('logger');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($serviceMock, $logger);\n\n $exception = new \\Exception('Network timeout');\n\n // Expect syncRemotelyDeletedObjects to throw an exception\n $serviceMock->expects($this->once())\n ->method('syncRemotelyDeletedObjects')\n ->with(\\Jiminny\\Enums\\CrmObject::OPPORTUNITY)\n ->willThrowException($exception);\n\n // Additional log parameters\n $logParams = [\n 'syncType' => 'full',\n 'batchSize' => 100,\n ];\n\n // Expect warning to be logged with merged parameters\n $logger->expects($this->once())\n ->method('warning')\n ->with(\n '[Salesforce] Remotely deleted objects sync failed',\n [\n 'objectType' => 'opportunity',\n 'teamId' => 'team-uuid-789',\n 'reason' => 'Network timeout',\n 'syncType' => 'full',\n 'batchSize' => 100,\n ]\n );\n\n // Call the protected method using reflection\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');\n $method->setAccessible(true);\n\n // Should not re-throw the exception, just log it\n $method->invoke($serviceMock, \\Jiminny\\Enums\\CrmObject::OPPORTUNITY, $logParams);\n\n $this->assertTrue(true); // Test completed successfully\n }\n\n /**\n * @dataProvider crmObjectProvider\n */\n public function testSyncRemotelyDeletedObjectsWithErrorHandlingDifferentCrmObjects(\\Jiminny\\Enums\\CrmObject $crmObject): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['syncRemotelyDeletedObjects'])\n ->getMock();\n\n // Mock team\n $team = $this->createMock(Team::class);\n $team->method('getUuid')->willReturn('team-uuid-test');\n $serviceMock->team = $team;\n\n // Mock logger to verify warning is logged\n $logger = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n\n // Use reflection to set the protected logger property\n $loggerReflectionClass = new \\ReflectionClass($serviceMock);\n $loggerProperty = $loggerReflectionClass->getProperty('logger');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($serviceMock, $logger);\n\n $exception = new \\Exception('Test error');\n\n // Expect syncRemotelyDeletedObjects to throw an exception\n $serviceMock->expects($this->once())\n ->method('syncRemotelyDeletedObjects')\n ->with($crmObject)\n ->willThrowException($exception);\n\n // Expect warning to be logged with correct entity type\n $logger->expects($this->once())\n ->method('warning')\n ->with(\n '[Salesforce] Remotely deleted objects sync failed',\n [\n 'objectType' => $crmObject->value,\n 'teamId' => 'team-uuid-test',\n 'reason' => 'Test error',\n ]\n );\n\n // Call the protected method using reflection\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');\n $method->setAccessible(true);\n\n $method->invoke($serviceMock, $crmObject);\n\n $this->assertTrue(true); // Test completed successfully\n }\n\n public function testHandleObjectDeletionWithDeletedEntity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['deleteCrmObject'])\n ->getMock();\n\n $entity = $this->createMock(\\Jiminny\\Models\\Account::class);\n $crmData = ['IsDeleted' => true];\n\n $serviceMock->expects($this->once())\n ->method('deleteCrmObject')\n ->with($entity);\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('handleObjectDeletion');\n $method->setAccessible(true);\n\n $method->invoke($serviceMock, $entity, $crmData);\n }\n\n public function testHandleObjectDeletionWithNonDeletedEntity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['deleteCrmObject'])\n ->getMock();\n\n $entity = $this->createMock(\\Jiminny\\Models\\Contact::class);\n $crmData = ['IsDeleted' => false];\n\n $serviceMock->expects($this->never())\n ->method('deleteCrmObject');\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('handleObjectDeletion');\n $method->setAccessible(true);\n\n $method->invoke($serviceMock, $entity, $crmData);\n }\n\n public function testDeleteCrmObjectWithValidEntity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['dispatchDeleteCrmObjectJob'])\n ->getMock();\n\n $entity = $this->createMock(\\Jiminny\\Models\\Lead::class);\n $entity->expects($this->once())->method('delete');\n\n $serviceMock->expects($this->once())\n ->method('dispatchDeleteCrmObjectJob')\n ->with($entity);\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('deleteCrmObject');\n $method->setAccessible(true);\n\n $method->invoke($serviceMock, $entity);\n }\n\n public function testDeleteCrmObjectWithNullEntity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['dispatchDeleteCrmObjectJob'])\n ->getMock();\n\n $serviceMock->expects($this->never())\n ->method('dispatchDeleteCrmObjectJob');\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('deleteCrmObject');\n $method->setAccessible(true);\n\n $method->invoke($serviceMock, null);\n }\n\n public function testDispatchDeleteCrmObjectJobWithNullEntity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($service);\n $method = $reflection->getMethod('dispatchDeleteCrmObjectJob');\n $method->setAccessible(true);\n\n // Should return early without dispatching - no exception expected\n $method->invoke($service, null);\n\n $this->assertTrue(true); // Test completed successfully\n }\n\n public function testDispatchDeleteCrmObjectJobWithUnsupportedEntity(): void\n {\n $this->expectException(\\TypeError::class);\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n $unsupportedEntity = $this->createMock(\\stdClass::class);\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($service);\n $method = $reflection->getMethod('dispatchDeleteCrmObjectJob');\n\n // This will throw TypeError due to union type constraint\n $method->invoke($service, $unsupportedEntity);\n }\n\n public function testHandleEntityDeletionByProviderIdMethodExists(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n // Test that the method exists and is accessible via reflection\n $reflection = new \\ReflectionClass($service);\n $method = $reflection->getMethod('handleEntityDeletionByProviderId');\n $method->setAccessible(true);\n\n // Verify method exists and has correct parameters\n $this->assertTrue($method->isProtected());\n $this->assertEquals(2, $method->getNumberOfParameters());\n\n $parameters = $method->getParameters();\n $this->assertEquals('targetEntity', $parameters[0]->getName());\n $this->assertEquals('crmData', $parameters[1]->getName());\n }\n\n public function testSyncRemotelyDeletedObjectsWithNoResults(): void\n {\n // Create a real service instance to avoid mock property issues\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n // Mock queryHandler to throw NoResultsException\n $queryHandler = $this->createMock(QueryHandler::class);\n $queryHandler->expects($this->once())\n ->method('queryDeleted')\n ->with('Opportunity')\n ->willThrowException(new NoResultsException('No results'));\n\n // Set the queryHandler using reflection on the real service\n $reflection = new \\ReflectionClass($service);\n $queryHandlerProperty = $reflection->getProperty('queryHandler');\n $queryHandlerProperty->setAccessible(true);\n $queryHandlerProperty->setValue($service, $queryHandler);\n\n $result = self::invokePrivateMethod('syncRemotelyDeletedObjects', $service, [CrmObject::OPPORTUNITY]);\n\n $this->assertFalse($result);\n }\n\n public function testSyncRemotelyDeletedObjectsWithEmptyResults(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n // Mock queryHandler to return empty results\n $queryResult = $this->createMock(QueryResults::class);\n $queryResult->method('getResults')->willReturn([]);\n\n $queryHandler = $this->createMock(QueryHandler::class);\n $queryHandler->expects($this->once())\n ->method('queryDeleted')\n ->with('Opportunity')\n ->willReturn($queryResult);\n\n // Set the queryHandler using reflection on the real service\n $reflection = new \\ReflectionClass($service);\n $queryHandlerProperty = $reflection->getProperty('queryHandler');\n $queryHandlerProperty->setAccessible(true);\n $queryHandlerProperty->setValue($service, $queryHandler);\n\n $result = self::invokePrivateMethod('syncRemotelyDeletedObjects', $service, [CrmObject::OPPORTUNITY]);\n\n $this->assertFalse($result);\n }\n\n public function testSyncRemotelyDeletedObjectsWithUnsupportedCrmObject(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n // Mock queryHandler to return some deleted objects so we reach the match statement\n $deletedObjects = [\n ['id' => 'task1'],\n ['id' => 'task2'],\n ];\n $queryResult = $this->createMock(QueryResults::class);\n $queryResult->method('getResults')->willReturn($deletedObjects);\n\n $queryHandler = $this->createMock(QueryHandler::class);\n $queryHandler->expects($this->once())\n ->method('queryDeleted')\n ->with('Task') // ucfirst('task') = 'Task'\n ->willReturn($queryResult);\n\n self::setPrivateProperty($service, 'queryHandler', $queryHandler);\n\n $this->expectException(InvalidArgumentException::class);\n $this->expectExceptionMessage('Unsupported CrmObject: task');\n\n self::invokePrivateMethod('syncRemotelyDeletedObjects', $service, [CrmObject::TASK]);\n }\n\n public static function crmObjectProvider(): array\n {\n return [\n 'Account' => [CrmObject::ACCOUNT],\n 'Contact' => [CrmObject::CONTACT],\n 'Lead' => [CrmObject::LEAD],\n 'Opportunity' => [CrmObject::OPPORTUNITY],\n ];\n }\n\n public function testVerifyTaskExistsReturnsTrue(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-123', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-123');\n $activity->method('getId')->willReturn(456);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-123', ['Id', 'IsDeleted'])\n ->willReturn(['Id' => 'task-123', 'IsDeleted' => false]);\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertTrue($result);\n }\n\n public function testVerifyTaskExistsReturnsTrueForEvent(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:event-123', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('event-123');\n $activity->method('getId')->willReturn(456);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_EVENT);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Event', 'event-123', ['Id', 'IsDeleted'])\n ->willReturn(['Id' => 'event-123', 'IsDeleted' => false]);\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertTrue($result);\n }\n\n public function testVerifyTaskExistsReturnsFalseWhenDeleted(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-456', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-456');\n $activity->method('getId')->willReturn(789);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-456', ['Id', 'IsDeleted'])\n ->willReturn(['Id' => 'task-456', 'IsDeleted' => true]);\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertFalse($result);\n }\n\n public function testVerifyTaskExistsReturnsFalseWhenNotFound(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-999', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-999');\n $activity->method('getId')->willReturn(999);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-999', ['Id', 'IsDeleted'])\n ->willThrowException(new \\Jiminny\\Exceptions\\HttpNotFoundException('Task not found'));\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertFalse($result);\n }\n\n public function testVerifyTaskExistsReturnsFalseWhenNoPlaybook(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-no-playbook', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-no-playbook');\n $activity->method('getId')->willReturn(111);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn(null);\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertFalse($result);\n }\n\n public function testVerifyTaskExistsThrowsExceptionForTransientErrors(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-error', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-error');\n $activity->method('getId')->willReturn(888);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-error', ['Id', 'IsDeleted'])\n ->willThrowException(new \\RuntimeException('Network timeout'));\n\n $this->expectException(\\RuntimeException::class);\n $this->expectExceptionMessage('Network timeout');\n\n $service->verifyTaskExists($activity);\n }\n\n public function testVerifyTaskExistsCachesResults(): void\n {\n $cachedValue = null;\n Cache::shouldReceive('remember')\n ->twice()\n ->with('crm_task_exists:123:task-cached', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(function ($key, $ttl, $callback) use (&$cachedValue) {\n if ($cachedValue === null) {\n $cachedValue = $callback();\n }\n\n return $cachedValue;\n });\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-cached');\n $activity->method('getId')->willReturn(555);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-cached', ['Id', 'IsDeleted'])\n ->willReturn(['Id' => 'task-cached', 'IsDeleted' => false]);\n\n $result1 = $service->verifyTaskExists($activity);\n $result2 = $service->verifyTaskExists($activity);\n\n $this->assertTrue($result1);\n $this->assertTrue($result2);\n }\n\n public function testVerifyTaskExistsReturnsFalseForHttpBadRequestException(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-400', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-400');\n $activity->method('getId')->willReturn(400);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-400', ['Id', 'IsDeleted'])\n ->willThrowException(new \\Jiminny\\Exceptions\\HttpBadRequestException('Bad request'));\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertFalse($result);\n }\n\n public function testImportOpportunitySkipsWhenNoProfileAndNoAccount(): void\n {\n $crmData = [\n 'Id' => 'SF-NO-USER-1',\n 'Name' => 'Test Opportunity',\n 'OwnerId' => 'owner-no-profile',\n // No AccountId\n ];\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(\\Illuminate\\Events\\Dispatcher::class); // ← ADD THIS\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n\n $service = new Service(\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService\n );\n\n $config = $this->createMock(Configuration::class);\n\n // Mock profiles relation returning null (no profile found)\n $profilesRelation = \\Mockery::mock(\\Illuminate\\Database\\Eloquent\\Relations\\HasMany::class);\n $profilesRelation->shouldReceive('where')->with('crm_provider_id', 'owner-no-profile')->andReturnSelf();\n $profilesRelation->shouldReceive('first')->andReturn(null);\n\n $config->expects($this->once())\n ->method('profiles')\n ->willReturn($profilesRelation);\n\n $team = $this->createMock(Team::class);\n $team->method('getId')->willReturn(1);\n\n $logger = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n $logger->expects($this->once())\n ->method('error')\n ->with(\n '[Salesforce] | Skip import, no user_id found',\n ['id' => 'SF-NO-USER-1']\n );\n\n $reflection = new \\ReflectionClass($service);\n\n $configProperty = $reflection->getProperty('config');\n $configProperty->setAccessible(true);\n $configProperty->setValue($service, $config);\n\n $teamProperty = $reflection->getProperty('team');\n $teamProperty->setAccessible(true);\n $teamProperty->setValue($service, $team);\n\n $loggerProperty = $reflection->getProperty('logger');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($service, $logger);\n\n // Initialize profile property to avoid \"must not be accessed before initialization\" error\n $profileProperty = $reflection->getProperty('profile');\n $profileProperty->setAccessible(true);\n $profileProperty->setValue($service, null);\n\n $result = self::invokePrivateMethod('importOpportunity', $service, [$crmData]);\n\n $this->assertNull($result);\n }\n\n public function testImportOpportunitySkipsWhenProfileNotFound(): void\n {\n $crmData = [\n 'Id' => 'SF-NO-USER-2',\n 'Name' => 'Test Opportunity',\n 'OwnerId' => 'owner-not-found',\n // No AccountId - avoid complex account processing\n ];\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $eventDispatcher = $this->createMock(\\Illuminate\\Events\\Dispatcher::class); // ← ADD THIS\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n\n\n $service = new Service(\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService\n );\n\n $config = $this->createMock(Configuration::class);\n\n // Mock profiles relation returning null (no profile found)\n $profilesRelation = \\Mockery::mock(\\Illuminate\\Database\\Eloquent\\Relations\\HasMany::class);\n $profilesRelation->shouldReceive('where')->with('crm_provider_id', 'owner-not-found')->andReturnSelf();\n $profilesRelation->shouldReceive('first')->andReturn(null);\n\n $config->expects($this->once())\n ->method('profiles')\n ->willReturn($profilesRelation);\n\n $team = $this->createMock(Team::class);\n $team->method('getId')->willReturn(1);\n\n $logger = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n $logger->expects($this->once())\n ->method('error')\n ->with(\n '[Salesforce] | Skip import, no user_id found',\n ['id' => 'SF-NO-USER-2']\n );\n\n $reflection = new \\ReflectionClass($service);\n\n $configProperty = $reflection->getProperty('config');\n $configProperty->setAccessible(true);\n $configProperty->setValue($service, $config);\n\n $teamProperty = $reflection->getProperty('team');\n $teamProperty->setAccessible(true);\n $teamProperty->setValue($service, $team);\n\n $loggerProperty = $reflection->getProperty('logger');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($service, $logger);\n\n // Initialize profile property\n $profileProperty = $reflection->getProperty('profile');\n $profileProperty->setAccessible(true);\n $profileProperty->setValue($service, null);\n\n $result = self::invokePrivateMethod('importOpportunity', $service, [$crmData]);\n\n $this->assertNull($result);\n }\n}\n\nclass HasManyExtended extends HasMany\n{\n public function where()\n {\n }\n\n public function first()\n {\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false}]...
|
-219575626138687605
|
-6520523286572803291
|
click
|
accessibility
|
NULL
|
Project: faVsco.js, menu
JY-20963-fix-import-on-de Project: faVsco.js, menu
JY-20963-fix-import-on-deleted-entity, menu
Start Listening for PHP Debug Connections
ServiceTest
Run 'ServiceTest'
Debug 'ServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Built-in Preview
Chrome
Firefox
Safari
Sync Changes
Hide This Notification
Code changed:
Hide
4
32
168
1
28
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Tests\Unit\Services\Crm\Salesforce;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Events\Dispatcher;
use Illuminate\Support\Facades\Cache;
use Jiminny\Component\Country\CountriesMap;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Enums\CrmObject;
use Jiminny\Exceptions\InvalidArgumentException;
use Jiminny\Exceptions\NoResultsException;
use Jiminny\Integrations\PlaybookResolver;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Playbook;
use Jiminny\Models\Team;
use Jiminny\Models\TeamSettings;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\ContactRoleRepository;
use Jiminny\Repositories\Crm\FieldDataRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\Salesforce\Client;
use Jiminny\Services\Crm\Salesforce\PayloadBuilder;
use Jiminny\Services\Crm\Salesforce\QueryBuilder;
use Jiminny\Services\Crm\Salesforce\QueryHandler;
use Jiminny\Services\Crm\Salesforce\QueryIterator;
use Jiminny\Services\Crm\Salesforce\QueryResults;
use Jiminny\Services\Crm\Salesforce\Service;
use PHPUnit\Framework\MockObject\MockObject;
use Tests\TestCase;
use Tests\Unit\Traits\TestPrivateMethod;
class ServiceTest extends TestCase
{
use TestPrivateMethod;
public function testFetchAndAssociateRelatedActivity(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$payloadBuilder->method('addCustomLogicFieldsPayload')
->willReturnCallback(function ($activity, $payload) {
return $payload;
});
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods(['fetchRelatedActivity', 'getPlaybook', 'getPlaybookCategory', 'updateRecord'])
->getMock();
$serviceMock->expects($this->once())
->method('fetchRelatedActivity')
->willReturn([
'Id' => 'testId',
'Type' => null,
'OwnerId' => 'testerUser',
'Description' => 'Test description',
]);
$user = $this->createMock(User::class);
$team = $this->createMock(Team::class);
$user->method('getAttribute')->with('team')->willReturn($team);
$playbook = $this->createMock(Playbook::class);
$playbook->method('getActivityField')->willReturn(null);
$playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_EVENT);
$serviceMock->expects($this->once())
->method('getPlaybook')
->with($user)
->willReturn($playbook);
$serviceMock->expects($this->never())
->method('getPlaybookCategory');
$serviceMock->expects($this->never())
->method('updateRecord');
$fieldDataRepository = $this->createMock(FieldDataRepository::class);
$fieldDataRepository->method('getActivityFieldData')->willReturn(collect([]));
app()->instance(FieldDataRepository::class, $fieldDataRepository);
$config = $this->createMock(Configuration::class);
$profilesRelation = $this->getMockBuilder(\Illuminate\Database\Eloquent\Relations\HasMany::class)
->disableOriginalConstructor()
->onlyMethods(['get'])
->addMethods(['where'])
->getMock();
$profilesRelation->method('where')->willReturnSelf();
$profilesRelation->method('get')->willReturn(collect([]));
$config->method('profiles')->willReturn($profilesRelation);
$serviceMock->config = $config;
$serviceMock->profile = null;
$actualStartTime = \Carbon\Carbon::now();
$activity = $this->getMockBuilder(Activity::class)
->disableOriginalConstructor()
->onlyMethods(['update', 'hasProspect'])
->getMock();
$activity->method('update')->willReturn(true);
$activity->method('hasProspect')->willReturn(true);
$activity->type = Activity::TYPE_CONFERENCE;
$activity->provider = Activity::PROVIDER_TWILIO;
$activity->lead_id = 1;
$activity->user_id = 0;
$activity->id_string = 'test-activity-id';
$activity->user = $user;
$activity->actual_start_time = $actualStartTime;
$activity->uuid = 'c53d8320-f556-4cee-a2f8-5f232f454ca4';
app()->bind(PlaybookResolver::class, function () use ($user) {
$playbook = $this->createMock(Playbook::class);
$playbookResolver = $this->createMock(PlaybookResolver::class);
$playbookResolver->expects($this->once())
->method('resolvePlaybookByUser')
->with($user)
->willReturn($playbook);
return $playbookResolver;
});
$data = $serviceMock->fetchAndAssociateRelatedActivity($activity);
$this->assertInstanceOf(Activity::class, $data);
$this->assertEquals(Activity::TYPE_CONFERENCE, $data->getType());
$this->assertEquals($actualStartTime->getTimestamp(), $data->getActualStartTime()->getTimestamp());
}
public function testFetchAndAssociateRelatedActivitySkipsForTaskBasedPlaybook(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods(['fetchRelatedActivity', 'getPlaybook'])
->getMock();
$user = $this->createMock(User::class);
$playbook = $this->createMock(Playbook::class);
$playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);
$playbook->method('getId')->willReturn(123);
$serviceMock->expects($this->once())
->method('getPlaybook')
->with($user)
->willReturn($playbook);
$serviceMock->expects($this->never())
->method('fetchRelatedActivity');
$activity = $this->getMockBuilder(Activity::class)
->disableOriginalConstructor()
->onlyMethods(['hasProspect', 'getUuid'])
->getMock();
$activity->method('hasProspect')->willReturn(true);
$activity->method('getUuid')->willReturn('c53d8320-f556-4cee-a2f8-5f232f454ca4');
$activity->type = Activity::TYPE_CONFERENCE;
$activity->actual_start_time = \Carbon\Carbon::now();
$activity->user = $user;
$result = $serviceMock->fetchAndAssociateRelatedActivity($activity);
$this->assertNull($result);
}
public function testFetchAndAssociateRelatedActivityReturnsNullForNonConference(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$serviceMock = new Service(
client: $client,
payloadBuilder: $payloadBuilder,
eventDispatcher: $eventDispatcher,
countriesMap: $countriesMap,
prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)
);
$activity = $this->getMockBuilder(Activity::class)
->disableOriginalConstructor()
->getMock();
$activity->type = Activity::TYPE_SOFTPHONE;
$result = $serviceMock->fetchAndAssociateRelatedActivity($activity);
$this->assertNull($result);
}
public function testFetchAndAssociateRelatedActivityReturnsNullWhenNoStartTime(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$serviceMock = new Service(
client: $client,
payloadBuilder: $payloadBuilder,
eventDispatcher: $eventDispatcher,
countriesMap: $countriesMap,
prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)
);
$activity = $this->getMockBuilder(Activity::class)
->disableOriginalConstructor()
->getMock();
$activity->type = Activity::TYPE_CONFERENCE;
$activity->actual_start_time = null;
$activity->scheduled_start_time = null;
$result = $serviceMock->fetchAndAssociateRelatedActivity($activity);
$this->assertNull($result);
}
public function testFetchAndAssociateRelatedActivityReturnsNullWhenNoProspect(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods(['getPlaybook'])
->getMock();
$serviceMock->expects($this->never())
->method('getPlaybook');
$activity = $this->getMockBuilder(Activity::class)
->disableOriginalConstructor()
->onlyMethods(['hasProspect', 'getUuid'])
->getMock();
$activity->method('hasProspect')->willReturn(false);
$activity->method('getUuid')->willReturn('c53d8320-f556-4cee-a2f8-5f232f454ca4');
$activity->type = Activity::TYPE_CONFERENCE;
$activity->actual_start_time = \Carbon\Carbon::now();
$result = $serviceMock->fetchAndAssociateRelatedActivity($activity);
$this->assertNull($result);
}
public function testMatchExactlyByEmail(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods([])
->getMock();
$profile = new Profile();
$profile->setAttribute('id', bin2hex(random_bytes(8)));
$serviceMock->profile = $profile;
$team = $this->createMock(Team::class);
$serviceMock->team = $team;
$data = $serviceMock->matchExactlyByEmail(bin2hex(random_bytes(8)) . '[EMAIL]');
$this->assertEquals(null, $data);
}
public function testMatchDomainFromEmail(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$queryIterator = $this->createMock(QueryIterator::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$config = $this->createMock(Configuration::class);
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('search')
->willReturn($queryIterator);
return $handler;
});
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods(['convertCrmData'])
->getMock();
$profile = new Profile();
$profile->account_fields = 'Field1, Field2, Field3';
$serviceMock->profile = $profile;
$serviceMock->expects($this->once())
->method('convertCrmData')
->willReturn(['test']);
$this->app->bind(QueryBuilder::class, function () {
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->expects($this->once())
->method('buildMatchByDomainQuery')
->with('[EMAIL]')
->willReturn('FIND {[EMAIL]} IN ALL FIELDS RETURNING Account(Id)');
return $queryBuilder;
});
$team = $this->createMock(Team::class);
$serviceMock->team = $team;
$serviceMock->config = $config;
$data = $serviceMock->matchByDomain('[EMAIL]');
$this->assertEquals(['test'], $data);
}
public function testBuildTaskSearchFields(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$service = new Service(
client: $client,
payloadBuilder: $payloadBuilder,
eventDispatcher: $eventDispatcher,
countriesMap: $countriesMap,
prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)
);
$fields = $service->buildTaskSearchFields();
$expectedFields = ['Id', 'WhoId', 'WhatId', 'AccountId'];
$this->assertEquals($expectedFields, $fields);
}
public function testMapCrmObjects(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$service = new Service(
client: $client,
payloadBuilder: $payloadBuilder,
eventDispatcher: $eventDispatcher,
countriesMap: $countriesMap,
prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class),
);
$sampleTask = [
'WhoId' => '003sampleWhoId',
'AccountId' => 'sampleAccountId',
'WhatId' => 'sampleWhatId',
];
$activityData = $service->mapCrmObjects($sampleTask);
$expectedActivityData = [
'contact' => '003sampleWhoId',
'account' => 'sampleAccountId',
'opportunity' => 'sampleWhatId',
];
$this->assertEquals($expectedActivityData, $activityData);
}
public function testGetInstalledAppVersion(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$queryIterator = $this->createMock(QueryIterator::class);
$queryIterator->expects($this->any())
->method('current')->willReturn([
'SubscriberPackageVersion' => [
'MajorVersion' => '1',
'MinorVersion' => '0',
'PatchVersion' => '1',
'BuildNumber' => '0',
],
]);
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('metadata')
->willReturn($queryIterator);
return $handler;
});
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods(array_diff(get_class_methods(Service::class), ['getInstalledAppVersion']))
->getMock();
$version = $serviceMock->getInstalledAppVersion();
$this->assertEquals('1010', $version);
}
public function testSyncProfiles(): void
{
$userToSearch = null;
$team = $this->createMock(Team::class);
$config = $this->createMockedConfiguration();
$config->expects($this->once())
->method('getId')
->willReturn(1);
$salesforceUser = [
'Email' => '[EMAIL]',
'UserPreferencesLightningExperiencePreferred' => true,
'CallCenterId' => '123',
'Id' => '456',
'ProfileId' => '789',
];
app()->bind(QueryBuilder::class, function () use ($userToSearch) {
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->expects($this->once())
->method('buildGetUsersQuery')
->with($userToSearch)
->willReturn('SELECT * FROM Users');
return $queryBuilder;
});
$queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('query')
->with('SELECT * FROM Users')
->willReturn($queryIterator);
return $handler;
});
$user = $this->createMock(User::class);
$user->expects($this->once())
->method('getId')
->willReturn(123);
$this->mockTeamRepository($team, $salesforceUser, $user);
$profileRepository = $this->createMock(ProfileRepository::class);
$profileRepository->expects($this->once())
->method('updateOrCreateProfile')
->with(
$user,
[
'crm_configuration_id' => 1,
'crm_provider_id' => '456',
],
[
'user_id' => 123,
'edition' => Profile::EDITION_LIGHTNING,
'has_external_cti' => true,
'crm_profile_id' => '789',
]
)
->willReturn(new Profile());
$this->app->instance(ProfileRepository::class, $profileRepository);
$serviceMock = $this->getServiceMock();
$serviceMock->team = $team;
$serviceMock->config = $config;
$result = $serviceMock->syncProfiles($userToSearch);
$this->assertNull($result);
}
public function testSyncProfilesEmailIsNull(): void
{
$userToSearch = $this->createMock(User::class);
$salesforceUser = [
'Email' => null,
];
app()->bind(QueryBuilder::class, function () {
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->expects($this->once())
->method('buildGetUsersQuery')
->with(null)
->willReturn('SELECT * FROM Users');
return $queryBuilder;
});
$queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->exactly(2))
->method('query')
->willReturn($queryIterator);
return $handler;
});
$team = $this->createMock(Team::class);
$user = $this->createMock(User::class);
$this->mockTeamRepository($team, $salesforceUser, $user, false);
$config = $this->createMock(Configuration::class);
$serviceMock = $this->getServiceMock();
$serviceMock->team = $team;
$serviceMock->config = $config;
$profile = $serviceMock->syncProfiles(null);
$this->assertNull($profile);
}
public function testSyncProfilesUserToSearchMatchesCurrentUser(): void
{
$userToSearch = $this->createMock(User::class);
$userToSearch->expects($this->once())
->method('getId')
->willReturn(123);
$team = $this->createMock(Team::class);
$config = $this->createMock(Configuration::class);
$config->expects($this->once())
->method('getId')
->willReturn(1);
$salesforceUser = [
'Email' => '[EMAIL]',
'UserPreferencesLightningExperiencePreferred' => true,
'CallCenterId' => '123',
'Id' => '456',
'ProfileId' => '789',
];
app()->bind(QueryBuilder::class, function () use ($userToSearch) {
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->expects($this->once())
->method('buildGetUsersQuery')
->with($userToSearch)
->willReturn('SELECT * FROM Users');
return $queryBuilder;
});
$queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('query')
->with('SELECT * FROM Users')
->willReturn($queryIterator);
return $handler;
});
$user = $this->createMock(User::class);
$user->expects($this->exactly(2))
->method('getId')
->willReturn(123);
$this->mockTeamRepository($team, $salesforceUser, $user);
$profileRepository = $this->createMock(ProfileRepository::class);
$profileRepository->expects($this->once())
->method('updateOrCreateProfile')
->with(
$user,
[
'crm_configuration_id' => 1,
'crm_provider_id' => '456',
],
[
'user_id' => 123,
'edition' => Profile::EDITION_LIGHTNING,
'has_external_cti' => true,
'crm_profile_id' => '789',
]
)
->willReturn(new Profile());
$this->app->instance(ProfileRepository::class, $profileRepository);
$serviceMock = $this->getServiceMock();
$serviceMock->team = $team;
$serviceMock->config = $config;
$profile = $serviceMock->syncProfiles($userToSearch);
$this->assertInstanceOf(Profile::class, $profile);
}
public function testSyncProfilesWithCustomValidation(): void
{
$userToSearch = null;
$team = $this->createMock(Team::class);
$config = $this->createMockedConfiguration();
$config->expects($this->atLeastOnce()) // Changed from once() to atLeastOnce()
->method('getId')
->willReturn(1);
$salesforceUser = [
'Email' => '[EMAIL]',
'UserPreferencesLightningExperiencePreferred' => true,
'CallCenterId' => '123',
'Id' => '456',
'ProfileId' => '789',
'CustomField' => 'CustomValue',
];
$customRules = [
['field' => 'CustomField', 'value' => 'CustomValue'],
];
$this->mockQueryBuilderAndHandler($userToSearch, [$salesforceUser]);
$user = $this->createMock(User::class);
$user->expects($this->once())
->method('getId')
->willReturn(123);
$this->mockTeamRepository($team, $salesforceUser, $user, true, $customRules);
$this->mockProfileRepository($user);
$serviceMock = $this->getServiceMock();
$serviceMock->team = $team;
$serviceMock->config = $config;
$profile = $serviceMock->syncProfiles($userToSearch);
$this->assertNull($profile);
}
public function testSyncProfilesWithCustomValidationFailing(): void
{
$userToSearch = null;
$team = $this->createMock(Team::class);
$config = $this->createMockedConfiguration();
$salesforceUser = [
'Email' => '[EMAIL]',
'UserPreferencesLightningExperiencePreferred' => true,
'CallCenterId' => '123',
'Id' => '456',
'ProfileId' => '789',
'CustomField' => 'WrongValue',
];
$customRules = [
['field' => 'CustomField', 'value' => 'CustomValue'],
];
$this->mockQueryBuilderAndHandler($userToSearch, [$salesforceUser]);
$teamRepository = $this->getMockForAbstractClass(TeamRepository::class, [], '', false, true, true, ['findActiveTeamMemberByEmail', 'getTeamSetting']);
$teamSettings = $this->createMock(TeamSettings::class);
$teamSettings->method('getValueType')
->willReturn('array');
$teamSettings->method('getValue')
->willReturn(json_encode($customRules));
$teamRepository->expects($this->once())
->method('getTeamSetting')
->with($team, 'custom_profile_validation')
->willReturn($teamSettings);
app()->bind(TeamRepository::class, function () use ($teamRepository) {
return $teamRepository;
});
$profileRepository = $this->createMock(ProfileRepository::class);
$profileRepository->expects($this->never())
->method('updateOrCreateProfile');
$this->app->instance(ProfileRepository::class, $profileRepository);
$serviceMock = $this->getServiceMock();
$serviceMock->team = $team;
$serviceMock->config = $config;
$result = $serviceMock->syncProfiles($userToSearch);
$this->assertNull($result);
}
public function testGetContactRolesFromCrm(): void
{
$contactRoles = [
[
'Id' => '1',
'ContactId' => 'Contact1',
'OpportunityId' => 'Opportunity1',
'Opportunity' => ['OwnerId' => 'Owner1'],
'IsPrimary' => true,
'Role' => 'Decision Maker',
],
];
$expectedResponse = [
[
'id' => '1',
'contactId' => 'Contact1',
'opportunityId' => 'Opportunity1',
'ownerId' => 'Owner1',
'isPrimary' => true,
'role' => 'Decision Maker',
],
];
$this->bindQueryIterator($contactRoles);
$serviceMock = $this->getServiceMock();
$data = $serviceMock->getContactRolesFromCrm(now()->subDay());
$this->assertEquals($expectedResponse, $data);
}
public function testGetContactRolesFromCrmNoResult(): void
{
$this->bindQueryIterator([]);
$serviceMock = $this->getServiceMock();
$data = $serviceMock->getContactRolesFromCrm(now()->subDay());
$this->assertEquals([], $data);
}
public function testSyncContactRoles(): void
{
$contactRoles = [
[
'id' => '1',
'contactId' => 'Contact1',
'opportunityId' => 'Opportunity1',
'ownerId' => 'Owner1',
'isPrimary' => true,
'role' => 'Decision Maker',
],
];
app()->bind(ContactRoleRepository::class, function () {
$contactRoleRepository = $this->createMock(ContactRoleRepository::class);
$contactRoleRepository->expects($this->once())
->method('saveContactRoles');
return $contactRoleRepository;
});
$serviceMock = $this->getServiceMock([
'getContactRolesFromCrm',
'syncRemotelyDeletedContactRoles',
'syncContact',
'syncOpportunity',
]);
$config = $this->createMock(Configuration::class);
$hasMany = $this->createMock(HasManyExtended::class);
$hasMany->expects($this->exactly(2))
->method('where')
->willReturn($hasMany);
$hasMany->expects($this->exactly(2))
->method('first')
->willReturn(
$this->createMock(Contact::class),
$this->createMock(Opportunity::class)
);
$config->expects($this->once())
->method('contacts')
->willReturn($hasMany);
$config->expects($this->once())
->method('opportunities')
->willReturn($hasMany);
$serviceMock->config = $config;
$serviceMock->expects($this->once())
->method('getContactRolesFromCrm')
->willReturn($contactRoles);
$serviceMock->expects($this->once())
->method('syncRemotelyDeletedContactRoles');
$data = $serviceMock->syncContactRoles(now()->subDay());
$this->assertEquals(1, $data);
}
public function testSyncRemotelyDeletedContactRoles(): void
{
$contactRoles = [
[
'id' => '1',
'crm_provider_id' => '1',
],
];
app()->bind(QueryHandler::class, function () use ($contactRoles) {
$queryResults = new QueryResults($contactRoles, 1, true, null);
$handler = $this->createMock(QueryHandler::class);
$handler->method('queryDeleted')
->willReturn($queryResults);
return $handler;
});
app()->bind(ContactRoleRepository::class, function () {
$contactRoleRepository = $this->createMock(ContactRoleRepository::class);
$contactRoleRepository->expects($this->once())
->method('deleteContactRoles');
return $contactRoleRepository;
});
$serviceMock = $this->getServiceMock();
$serviceMock->team = $this->createMock(Team::class);
$data = $this->invokePrivateMethod('syncRemotelyDeletedContactRoles', $serviceMock, []);
$this->assertTrue($data);
}
private function bindQueryIterator(array $queryResult): void
{
/** @var Client $client */
$client = $this->createMock(Client::class);
$queryIterator = new QueryIterator(
$client,
new QueryResults($queryResult, 1, true, null)
);
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('query')
->willReturn($queryIterator);
return $handler;
});
}
public static function getOpportunitySortOrderDataProvider(): array
{
return [
'all open recently updated' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED, ['LastModifiedDate DESC', true]],
'all open recently created' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED, ['CreatedDate DESC', true]],
'all open oldest created' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED, ['CreatedDate ASC', true]],
'all recently updated' => [Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED, ['LastModifiedDate DESC', false]],
'default' => ['unknown', ['LastModifiedDate DESC', true]],
];
}
private function createMockedConfiguration(): Configuration
{
$config = $this->createMock(Configuration::class);
$profilesRelation = $this->getMockBuilder(\Illuminate\Database\Eloquent\Relations\HasMany::class)
->disableOriginalConstructor()
->onlyMethods(['get'])
->addMethods(['where', 'first'])
->getMock();
$profilesRelation->method('where')->willReturnSelf();
$profilesRelation->method('get')->willReturn(collect([]));
$profilesRelation->method('first')->willReturn(null);
$config->method('profiles')->willReturn($profilesRelation);
return $config;
}
private function getServiceMock(array $onlyMethods = []): MockObject&Service
{
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$this->createMock(Client::class),
$this->createMock(PayloadBuilder::class),
$this->createMock(Dispatcher::class),
$this->createMock(CountriesMap::class),
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods($onlyMethods)
->getMock();
$serviceMock->profile = $this->createMock(Profile::class);
return $serviceMock;
}
private function mockQueryBuilderAndHandler($userToSearch, $salesforceUsers): void
{
app()->bind(QueryBuilder::class, function () use ($userToSearch) {
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->expects($this->once())
->method('buildGetUsersQuery')
->with($userToSearch)
->willReturn('SELECT * FROM Users');
return $queryBuilder;
});
$queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults($salesforceUsers, count($salesforceUsers), true, null));
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('query')
->willReturn($queryIterator);
return $handler;
});
}
private function mockTeamRepository(
Team $team,
array $salesforceUser,
?User $user = null,
bool $userSearch = true,
array $customRules = []
): void {
$teamRepository = $this->getMockForAbstractClass(TeamRepository::class, [], '', false, true, true, ['findActiveTeamMemberByEmail', 'getTeamSetting']);
if ($userSearch) {
$teamRepository->expects($this->once())
->method('findActiveTeamMemberByEmail')
->with($team, $salesforceUser['Email'])
->willReturn($user);
}
$teamSettings = $this->createMock(TeamSettings::class);
$teamSettings->method('getValueType')
->willReturn('array');
$teamSettings->method('getValue')
->willReturn(json_encode($customRules));
$teamRepository->expects($this->once())
->method('getTeamSetting')
->with($team, 'custom_profile_validation')
->willReturn($teamSettings);
app()->bind(TeamRepository::class, function () use ($teamRepository) {
return $teamRepository;
});
}
private function mockProfileRepository(User $user): void
{
$profileRepository = $this->createMock(ProfileRepository::class);
$profileRepository->expects($this->once())
->method('updateOrCreateProfile')
->with(
$user,
[
'crm_configuration_id' => 1,
'crm_provider_id' => '456',
],
[
'user_id' => 123,
'edition' => Profile::EDITION_LIGHTNING,
'has_external_cti' => true,
'crm_profile_id' => '789',
]
)
->willReturn(new Profile());
$this->app->instance(ProfileRepository::class, $profileRepository);
}
public function testBuildEnhancedNoteDecodesHtmlEntities(): void
{
$service = $this->getServiceMock(['createRecord']);
$profile = new Profile();
$profile->setAttribute('crm_provider_id', 'owner-123');
$service->profile = $profile;
$service->expects($this->exactly(2))
->method('createRecord')
->willReturnOnConsecutiveCalls('note-id-123', 'link-id-456');
$bodyWithEntities = 'Welch's current challenges and Facebook's Club';
$result = $this->invokePrivateMethod('buildEnhancedNote', $service, [
'Test Title',
$bodyWithEntities,
'object-id-789',
]);
$this->assertEquals('note-id-123', $result);
}
public function testBuildEnhancedNoteSanitizesWithoutQuotes(): void
{
$service = $this->getServiceMock(['createRecord']);
$profile = new Profile();
$profile->setAttribute('crm_provider_id', 'owner-456');
$service->profile = $profile;
$service->expects($this->exactly(2))
->method('createRecord')
->willReturnCallback(function ($type, $data) {
if ($type === 'ContentNote') {
$decoded = base64_decode($data['Content']);
$this->assertStringContainsString("Welch's", $decoded);
$this->assertStringNotContainsString(''', $decoded);
$this->assertStringNotContainsString('&#039;', $decoded);
$this->assertStringContainsString('<script>', $decoded);
return 'note-id-456';
}
return 'link-id-789';
});
$bodyWithMixedContent = "Welch's and <script>alert('xss')</script>";
$result = $this->invokePrivateMethod('buildEnhancedNote', $service, [
'Test Title',
$bodyWithMixedContent,
'object-id-123',
]);
$this->assertEquals('note-id-456', $result);
}
public function testBuildEnhancedNoteConvertsLineBreaks(): void
{
$service = $this->getServiceMock(['createRecord']);
$profile = new Profile();
$profile->setAttribute('crm_provider_id', 'owner-789');
$service->profile = $profile;
$service->expects($this->exactly(2))
->method('createRecord')
->willReturnCallback(function ($type, $data) {
if ($type === 'ContentNote') {
$decoded = base64_decode($data['Content']);
$this->assertStringContainsString('<br>', $decoded);
$this->assertStringNotContainsString('<br />', $decoded);
return 'note-id-789';
}
return 'link-id-012';
});
$bodyWithLineBreaks = "Line 1\nLine 2\nLine 3";
$result = $this->invokePrivateMethod('buildEnhancedNote', $service, [
'Test Title',
$bodyWithLineBreaks,
'object-id-456',
]);
$this->assertEquals('note-id-789', $result);
}
public function testBuildEnhancedNoteHandlesComplexScenario(): void
{
$service = $this->getServiceMock(['createRecord']);
$profile = new Profile();
$profile->setAttribute('crm_provider_id', 'owner-complex');
$service->profile = $profile;
$service->expects($this->exactly(2))
->method('createRecord')
->willReturnCallback(function ($type, $data) {
if ($type === 'ContentNote') {
$decoded = base64_decode($data['Content']);
$this->assertStringContainsString("Welch's", $decoded);
$this->assertStringContainsString("Facebook's Club", $decoded);
$this->assertStringContainsString("Arctics'", $decoded);
$this->assertStringNotContainsString(''', $decoded);
$this->assertStringNotContainsString('&#039;', $decoded);
$this->assertStringContainsString('<br>', $decoded);
$this->assertStringContainsString('<', $decoded);
$this->assertStringContainsString('>', $decoded);
return 'note-complex';
}
return 'link-complex';
});
$complexBody = "Summary:\n---------\nThe call focused on understanding Welch's current challenges and exploring how Arctics' Revenue Growth Management solutions could support their strategic goals.\n\n• John SMith discussed his role as a category advisor for Google and Facebook's Club, emphasizing the importance of market research and advising on product assortment.\n• Madona introduced Arctics' Virtual Shoppers AI, which simulates consumer <behavior> to optimize pricing and promotional strategies.";
$result = $this->invokePrivateMethod('buildEnhancedNote', $service, [
'Jiminny Transcription Summary',
$complexBody,
'task-id-001',
]);
$this->assertEquals('note-complex', $result);
}
public function testSyncRemotelyDeletedObjectsWithErrorHandlingSuccess(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$prospectPhotoPathService,
])
->onlyMethods(['syncRemotelyDeletedObjects'])
->getMock();
// Mock team
$team = $this->createMock(Team::class);
$team->method('getUuid')->willReturn('team-uuid-123');
$serviceMock->team = $team;
// Expect syncRemotelyDeletedObjects to be called once and succeed
$serviceMock->expects($this->once())
->method('syncRemotelyDeletedObjects')
->with(\Jiminny\Enums\CrmObject::ACCOUNT);
// Call the protected method using reflection
$reflection = new \ReflectionClass($serviceMock);
$method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');
$method->setAccessible(true);
// Should not throw any exceptions
$method->invoke($serviceMock, \Jiminny\Enums\CrmObject::ACCOUNT);
$this->assertTrue(true); // Test completed successfully
}
public function testSyncRemotelyDeletedObjectsWithErrorHandlingFailure(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$prospectPhotoPathService,
])
->onlyMethods(['syncRemotelyDeletedObjects'])
->getMock();
// Mock team
$team = $this->createMock(Team::class);
$team->method('getUuid')->willReturn('team-uuid-456');
$serviceMock->team = $team;
// Mock logger to verify warning is logged
$logger = $this->createMock(\Psr\Log\LoggerInterface::class);
// Use reflection to set the protected logger property
$reflection = new \ReflectionClass($serviceMock);
$loggerProperty = $reflection->getProperty('logger');
$loggerProperty->setAccessible(true);
$loggerProperty->setValue($serviceMock, $logger);
$exception = new \Exception('Sync failed due to API error');
// Expect syncRemotelyDeletedObjects to throw an exception
$serviceMock->expects($this->once())
->method('syncRemotelyDeletedObjects')
->with(\Jiminny\Enums\CrmObject::CONTACT)
->willThrowException($exception);
// Expect warning to be logged with correct message and parameters
$logger->expects($this->once())
->method('warning')
->with(
'[Salesforce] Remotely deleted objects sync failed',
[
'objectType' => 'contact',
'teamId' => 'team-uuid-456',
'reason' => 'Sync failed due to API error',
]
);
// Call the protected method using reflection
$reflection = new \ReflectionClass($serviceMock);
$method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');
$method->setAccessible(true);
// Should not re-throw the exception, just log it
$method->invoke($serviceMock, \Jiminny\Enums\CrmObject::CONTACT);
$this->assertTrue(true); // Test completed successfully
}
public function testSyncRemotelyDeletedObjectsWithErrorHandlingWithLogParams(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$prospectPhotoPathService,
])
->onlyMethods(['syncRemotelyDeletedObjects'])
->getMock();
// Mock team
$team = $this->createMock(Team::class);
$team->method('getUuid')->willReturn('team-uuid-789');
$serviceMock->team = $team;
// Mock logger to verify warning is logged
$logger = $this->createMock(\Psr\Log\LoggerInterface::class);
// Use reflection to set the protected logger property
$loggerReflection = new \ReflectionClass($serviceMock);
$loggerProperty = $loggerReflection->getProperty('logger');
$loggerProperty->setAccessible(true);
$loggerProperty->setValue($serviceMock, $logger);
$exception = new \Exception('Network timeout');
// Expect syncRemotelyDeletedObjects to throw an exception
$serviceMock->expects($this->once())
->method('syncRemotelyDeletedObjects')
->with(\Jiminny\Enums\CrmObject::OPPORTUNITY)
->willThrowException($exception);
// Additional log parameters
$logParams = [
'syncType' => 'full',
'batchSize' => 100,
];
// Expect warning to be logged with merged parameters
$logger->expects($this->once())
->method('warning')
->with(
'[Salesforce] Remotely deleted objects sync failed',
[
'objectType' => 'opportunity',
'teamId' => 'team-uuid-789',
'reason' => 'Network timeout',
'syncType' => 'full',
'batchSize' => 100,
]
);
// Call the protected method using reflection
$reflection = new \ReflectionClass($serviceMock);
$method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');
$method->setAccessible(true);...
|
70626
|
NULL
|
NULL
|
NULL
|
|
70633
|
2536
|
9
|
2026-05-22T10:26:04.567039+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779445564567_m1.jpg...
|
PhpStorm
|
faVsco.js – ServiceTest.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20963-fix-import-on-de Project: faVsco.js, menu
JY-20963-fix-import-on-deleted-entity, menu
Start Listening for PHP Debug Connections
ServiceTest
Run 'ServiceTest'...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JY-20963-fix-import-on-deleted-entity, menu","depth":5,"on_screen":true,"help_text":"Git Branch: JY-20963-fix-import-on-deleted-entity","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"ServiceTest","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'ServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
-8596058302812885561
|
-8635229662240991102
|
click
|
hybrid
|
NULL
|
Project: faVsco.js, menu
JY-20963-fix-import-on-de Project: faVsco.js, menu
JY-20963-fix-import-on-deleted-entity, menu
Start Listening for PHP Debug Connections
ServiceTest
Run 'ServiceTest'
SlackFile EditViewGoHistoryWindowHelpAPP (DOCKERDEV (-zsh)O $82APP (-zsh)tests/Unit/Services/Mail/0ffice/EmailApiClientTest.phptests/Unit/Services/Mail/TextRelayServiceTest.phptests/Unit/Services/RecallAI/RecallAIServiceTest.php79 files changed, 4254 insertions(+), 2020 deletions(-)create mode 100644•github/claude-reviewer/no-ticket-warning.txtcreate mode.github/claude-reviewer/scripts/fetch-jira-context.mjsdelete mode100644 app/Component/ES/Processor/BatchStatusInterface.phpcreate mode100644 app/Component/ES/Processor/RedisEntityExtractor.phpdelete mode100644 app/Component/ES/Processor/Traits/WorkerIdTrait.php100644 app/Component/ES/QueuePriorityEnum.phpcreate mode100644 front-end/src/components/DealInsights/Modals/__tests__/AskAnythingModacreatemode 100644 front-end/src/components/shared/AskAnything/__tests__/__snapshots__/Asdeletemode100644 front-end/src/components/shared/AskAnything/__tests__/__snapshots_/Ascreate mode100644 tests/Unit/Component/ES/Processor/RedisEntityExtractorTest.phpdelete mode100644 tests/Unit/Component/ES/Processor/Traits/WorkerIdTraitTest.phpcreate mode100644 tests/Unit/Component/ES/QueuePriorityEnumTest.phpcreate mode100644 tests/Unit/Notifications/Activities/SmsReceivedTest.phpcreate mode100644 tests/Unit/Services/Mail/TextRelayServiceTest.phplukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ co -b JY-20963-fix-imporSwitched to a new branch 'JY-20963-fix-import-on-deleted-entity'lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/Jiminny/app (JY-20963-fix-import-on-deleted-ent|docker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix--config=.php-cs-fixer.dist.phWhat's next:Try Docker Debug forseamless, persistent debugging tools in any container or image →Learn moreat [URL_WITH_CREDENTIALS] ~/jiminny/app (JY-20963-fix-import-on-deleted-ent)docker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.phPHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminski15 umconafi amp cont-fbteo-di5t. phPHP runtime: 8.3.30Running analysis on 7 cores with 10 files per process.Parallel runner is an experimental feature and may be unstable, use it at your own risk.Loaded config default from".php-cs-fixer.dist.php".5691/5691 [8100%Fixed 0 of 5691 files in 96.077 seconds, 67.00 MB memory usedWhat's next:Try Docker Debug for seamless, persistent debugging tools in anycontainer or imageLearn moreat [URL_WITH_CREDENTIALS] review requested by @VasilVasilev#12120 Jy 20842 add deleteevents for es by @Vasil Vasilev9 commits • 14 files changedJIRA: JY-20842Changes:• Introduce events and eventlisteners that allow sending"document deleted" events for ESconsumption - both Hard and Softdeleted documents will beremoved.• Loa...Show morejiminny/appAdded by Toast for GitHubMessage Toast+Aa..•...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
70631
|
2536
|
8
|
2026-05-22T10:25:55.872093+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779445555872_m1.jpg...
|
PhpStorm
|
faVsco.js – ServiceTest.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20963-fix-import-on-de Project: faVsco.js, menu
JY-20963-fix-import-on-deleted-entity, menu
Start Listening for PHP Debug Connections
ServiceTest
Run 'ServiceTest'
Debug 'ServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Built-in Preview
Chrome
Firefox
Safari
Sync Changes
Hide This Notification
Code changed:
Hide
4
32
168
1
28
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Tests\Unit\Services\Crm\Salesforce;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Events\Dispatcher;
use Illuminate\Support\Facades\Cache;
use Jiminny\Component\Country\CountriesMap;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Enums\CrmObject;
use Jiminny\Exceptions\InvalidArgumentException;
use Jiminny\Exceptions\NoResultsException;
use Jiminny\Integrations\PlaybookResolver;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Playbook;
use Jiminny\Models\Team;
use Jiminny\Models\TeamSettings;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\ContactRoleRepository;
use Jiminny\Repositories\Crm\FieldDataRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\Salesforce\Client;
use Jiminny\Services\Crm\Salesforce\PayloadBuilder;
use Jiminny\Services\Crm\Salesforce\QueryBuilder;
use Jiminny\Services\Crm\Salesforce\QueryHandler;
use Jiminny\Services\Crm\Salesforce\QueryIterator;
use Jiminny\Services\Crm\Salesforce\QueryResults;
use Jiminny\Services\Crm\Salesforce\Service;
use PHPUnit\Framework\MockObject\MockObject;
use Tests\TestCase;
use Tests\Unit\Traits\TestPrivateMethod;
class ServiceTest extends TestCase
{
use TestPrivateMethod;
public function testFetchAndAssociateRelatedActivity(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$payloadBuilder->method('addCustomLogicFieldsPayload')
->willReturnCallback(function ($activity, $payload) {
return $payload;
});
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods(['fetchRelatedActivity', 'getPlaybook', 'getPlaybookCategory', 'updateRecord'])
->getMock();
$serviceMock->expects($this->once())
->method('fetchRelatedActivity')
->willReturn([
'Id' => 'testId',
'Type' => null,
'OwnerId' => 'testerUser',
'Description' => 'Test description',
]);
$user = $this->createMock(User::class);
$team = $this->createMock(Team::class);
$user->method('getAttribute')->with('team')->willReturn($team);
$playbook = $this->createMock(Playbook::class);
$playbook->method('getActivityField')->willReturn(null);
$playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_EVENT);
$serviceMock->expects($this->once())
->method('getPlaybook')
->with($user)
->willReturn($playbook);
$serviceMock->expects($this->never())
->method('getPlaybookCategory');
$serviceMock->expects($this->never())
->method('updateRecord');
$fieldDataRepository = $this->createMock(FieldDataRepository::class);
$fieldDataRepository->method('getActivityFieldData')->willReturn(collect([]));
app()->instance(FieldDataRepository::class, $fieldDataRepository);
$config = $this->createMock(Configuration::class);
$profilesRelation = $this->getMockBuilder(\Illuminate\Database\Eloquent\Relations\HasMany::class)
->disableOriginalConstructor()
->onlyMethods(['get'])
->addMethods(['where'])
->getMock();
$profilesRelation->method('where')->willReturnSelf();
$profilesRelation->method('get')->willReturn(collect([]));
$config->method('profiles')->willReturn($profilesRelation);
$serviceMock->config = $config;
$serviceMock->profile = null;
$actualStartTime = \Carbon\Carbon::now();
$activity = $this->getMockBuilder(Activity::class)
->disableOriginalConstructor()
->onlyMethods(['update', 'hasProspect'])
->getMock();
$activity->method('update')->willReturn(true);
$activity->method('hasProspect')->willReturn(true);
$activity->type = Activity::TYPE_CONFERENCE;
$activity->provider = Activity::PROVIDER_TWILIO;
$activity->lead_id = 1;
$activity->user_id = 0;
$activity->id_string = 'test-activity-id';
$activity->user = $user;
$activity->actual_start_time = $actualStartTime;
$activity->uuid = 'c53d8320-f556-4cee-a2f8-5f232f454ca4';
app()->bind(PlaybookResolver::class, function () use ($user) {
$playbook = $this->createMock(Playbook::class);
$playbookResolver = $this->createMock(PlaybookResolver::class);
$playbookResolver->expects($this->once())
->method('resolvePlaybookByUser')
->with($user)
->willReturn($playbook);
return $playbookResolver;
});
$data = $serviceMock->fetchAndAssociateRelatedActivity($activity);
$this->assertInstanceOf(Activity::class, $data);
$this->assertEquals(Activity::TYPE_CONFERENCE, $data->getType());
$this->assertEquals($actualStartTime->getTimestamp(), $data->getActualStartTime()->getTimestamp());
}
public function testFetchAndAssociateRelatedActivitySkipsForTaskBasedPlaybook(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods(['fetchRelatedActivity', 'getPlaybook'])
->getMock();
$user = $this->createMock(User::class);
$playbook = $this->createMock(Playbook::class);
$playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);
$playbook->method('getId')->willReturn(123);
$serviceMock->expects($this->once())
->method('getPlaybook')
->with($user)
->willReturn($playbook);
$serviceMock->expects($this->never())
->method('fetchRelatedActivity');
$activity = $this->getMockBuilder(Activity::class)
->disableOriginalConstructor()
->onlyMethods(['hasProspect', 'getUuid'])
->getMock();
$activity->method('hasProspect')->willReturn(true);
$activity->method('getUuid')->willReturn('c53d8320-f556-4cee-a2f8-5f232f454ca4');
$activity->type = Activity::TYPE_CONFERENCE;
$activity->actual_start_time = \Carbon\Carbon::now();
$activity->user = $user;
$result = $serviceMock->fetchAndAssociateRelatedActivity($activity);
$this->assertNull($result);
}
public function testFetchAndAssociateRelatedActivityReturnsNullForNonConference(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$serviceMock = new Service(
client: $client,
payloadBuilder: $payloadBuilder,
eventDispatcher: $eventDispatcher,
countriesMap: $countriesMap,
prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)
);
$activity = $this->getMockBuilder(Activity::class)
->disableOriginalConstructor()
->getMock();
$activity->type = Activity::TYPE_SOFTPHONE;
$result = $serviceMock->fetchAndAssociateRelatedActivity($activity);
$this->assertNull($result);
}
public function testFetchAndAssociateRelatedActivityReturnsNullWhenNoStartTime(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$serviceMock = new Service(
client: $client,
payloadBuilder: $payloadBuilder,
eventDispatcher: $eventDispatcher,
countriesMap: $countriesMap,
prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)
);
$activity = $this->getMockBuilder(Activity::class)
->disableOriginalConstructor()
->getMock();
$activity->type = Activity::TYPE_CONFERENCE;
$activity->actual_start_time = null;
$activity->scheduled_start_time = null;
$result = $serviceMock->fetchAndAssociateRelatedActivity($activity);
$this->assertNull($result);
}
public function testFetchAndAssociateRelatedActivityReturnsNullWhenNoProspect(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods(['getPlaybook'])
->getMock();
$serviceMock->expects($this->never())
->method('getPlaybook');
$activity = $this->getMockBuilder(Activity::class)
->disableOriginalConstructor()
->onlyMethods(['hasProspect', 'getUuid'])
->getMock();
$activity->method('hasProspect')->willReturn(false);
$activity->method('getUuid')->willReturn('c53d8320-f556-4cee-a2f8-5f232f454ca4');
$activity->type = Activity::TYPE_CONFERENCE;
$activity->actual_start_time = \Carbon\Carbon::now();
$result = $serviceMock->fetchAndAssociateRelatedActivity($activity);
$this->assertNull($result);
}
public function testMatchExactlyByEmail(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods([])
->getMock();
$profile = new Profile();
$profile->setAttribute('id', bin2hex(random_bytes(8)));
$serviceMock->profile = $profile;
$team = $this->createMock(Team::class);
$serviceMock->team = $team;
$data = $serviceMock->matchExactlyByEmail(bin2hex(random_bytes(8)) . '[EMAIL]');
$this->assertEquals(null, $data);
}
public function testMatchDomainFromEmail(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$queryIterator = $this->createMock(QueryIterator::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$config = $this->createMock(Configuration::class);
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('search')
->willReturn($queryIterator);
return $handler;
});
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods(['convertCrmData'])
->getMock();
$profile = new Profile();
$profile->account_fields = 'Field1, Field2, Field3';
$serviceMock->profile = $profile;
$serviceMock->expects($this->once())
->method('convertCrmData')
->willReturn(['test']);
$this->app->bind(QueryBuilder::class, function () {
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->expects($this->once())
->method('buildMatchByDomainQuery')
->with('[EMAIL]')
->willReturn('FIND {[EMAIL]} IN ALL FIELDS RETURNING Account(Id)');
return $queryBuilder;
});
$team = $this->createMock(Team::class);
$serviceMock->team = $team;
$serviceMock->config = $config;
$data = $serviceMock->matchByDomain('[EMAIL]');
$this->assertEquals(['test'], $data);
}
public function testBuildTaskSearchFields(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$service = new Service(
client: $client,
payloadBuilder: $payloadBuilder,
eventDispatcher: $eventDispatcher,
countriesMap: $countriesMap,
prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)
);
$fields = $service->buildTaskSearchFields();
$expectedFields = ['Id', 'WhoId', 'WhatId', 'AccountId'];
$this->assertEquals($expectedFields, $fields);
}
public function testMapCrmObjects(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$service = new Service(
client: $client,
payloadBuilder: $payloadBuilder,
eventDispatcher: $eventDispatcher,
countriesMap: $countriesMap,
prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class),
);
$sampleTask = [
'WhoId' => '003sampleWhoId',
'AccountId' => 'sampleAccountId',
'WhatId' => 'sampleWhatId',
];
$activityData = $service->mapCrmObjects($sampleTask);
$expectedActivityData = [
'contact' => '003sampleWhoId',
'account' => 'sampleAccountId',
'opportunity' => 'sampleWhatId',
];
$this->assertEquals($expectedActivityData, $activityData);
}
public function testGetInstalledAppVersion(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$queryIterator = $this->createMock(QueryIterator::class);
$queryIterator->expects($this->any())
->method('current')->willReturn([
'SubscriberPackageVersion' => [
'MajorVersion' => '1',
'MinorVersion' => '0',
'PatchVersion' => '1',
'BuildNumber' => '0',
],
]);
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('metadata')
->willReturn($queryIterator);
return $handler;
});
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods(array_diff(get_class_methods(Service::class), ['getInstalledAppVersion']))
->getMock();
$version = $serviceMock->getInstalledAppVersion();
$this->assertEquals('1010', $version);
}
public function testSyncProfiles(): void
{
$userToSearch = null;
$team = $this->createMock(Team::class);
$config = $this->createMockedConfiguration();
$config->expects($this->once())
->method('getId')
->willReturn(1);
$salesforceUser = [
'Email' => '[EMAIL]',
'UserPreferencesLightningExperiencePreferred' => true,
'CallCenterId' => '123',
'Id' => '456',
'ProfileId' => '789',
];
app()->bind(QueryBuilder::class, function () use ($userToSearch) {
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->expects($this->once())
->method('buildGetUsersQuery')
->with($userToSearch)
->willReturn('SELECT * FROM Users');
return $queryBuilder;
});
$queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('query')
->with('SELECT * FROM Users')
->willReturn($queryIterator);
return $handler;
});
$user = $this->createMock(User::class);
$user->expects($this->once())
->method('getId')
->willReturn(123);
$this->mockTeamRepository($team, $salesforceUser, $user);
$profileRepository = $this->createMock(ProfileRepository::class);
$profileRepository->expects($this->once())
->method('updateOrCreateProfile')
->with(
$user,
[
'crm_configuration_id' => 1,
'crm_provider_id' => '456',
],
[
'user_id' => 123,
'edition' => Profile::EDITION_LIGHTNING,
'has_external_cti' => true,
'crm_profile_id' => '789',
]
)
->willReturn(new Profile());
$this->app->instance(ProfileRepository::class, $profileRepository);
$serviceMock = $this->getServiceMock();
$serviceMock->team = $team;
$serviceMock->config = $config;
$result = $serviceMock->syncProfiles($userToSearch);
$this->assertNull($result);
}
public function testSyncProfilesEmailIsNull(): void
{
$userToSearch = $this->createMock(User::class);
$salesforceUser = [
'Email' => null,
];
app()->bind(QueryBuilder::class, function () {
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->expects($this->once())
->method('buildGetUsersQuery')
->with(null)
->willReturn('SELECT * FROM Users');
return $queryBuilder;
});
$queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->exactly(2))
->method('query')
->willReturn($queryIterator);
return $handler;
});
$team = $this->createMock(Team::class);
$user = $this->createMock(User::class);
$this->mockTeamRepository($team, $salesforceUser, $user, false);
$config = $this->createMock(Configuration::class);
$serviceMock = $this->getServiceMock();
$serviceMock->team = $team;
$serviceMock->config = $config;
$profile = $serviceMock->syncProfiles(null);
$this->assertNull($profile);
}
public function testSyncProfilesUserToSearchMatchesCurrentUser(): void
{
$userToSearch = $this->createMock(User::class);
$userToSearch->expects($this->once())
->method('getId')
->willReturn(123);
$team = $this->createMock(Team::class);
$config = $this->createMock(Configuration::class);
$config->expects($this->once())
->method('getId')
->willReturn(1);
$salesforceUser = [
'Email' => '[EMAIL]',
'UserPreferencesLightningExperiencePreferred' => true,
'CallCenterId' => '123',
'Id' => '456',
'ProfileId' => '789',
];
app()->bind(QueryBuilder::class, function () use ($userToSearch) {
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->expects($this->once())
->method('buildGetUsersQuery')
->with($userToSearch)
->willReturn('SELECT * FROM Users');
return $queryBuilder;
});
$queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('query')
->with('SELECT * FROM Users')
->willReturn($queryIterator);
return $handler;
});
$user = $this->createMock(User::class);
$user->expects($this->exactly(2))
->method('getId')
->willReturn(123);
$this->mockTeamRepository($team, $salesforceUser, $user);
$profileRepository = $this->createMock(ProfileRepository::class);
$profileRepository->expects($this->once())
->method('updateOrCreateProfile')
->with(
$user,
[
'crm_configuration_id' => 1,
'crm_provider_id' => '456',
],
[
'user_id' => 123,
'edition' => Profile::EDITION_LIGHTNING,
'has_external_cti' => true,
'crm_profile_id' => '789',
]
)
->willReturn(new Profile());
$this->app->instance(ProfileRepository::class, $profileRepository);
$serviceMock = $this->getServiceMock();
$serviceMock->team = $team;
$serviceMock->config = $config;
$profile = $serviceMock->syncProfiles($userToSearch);
$this->assertInstanceOf(Profile::class, $profile);
}
public function testSyncProfilesWithCustomValidation(): void
{
$userToSearch = null;
$team = $this->createMock(Team::class);
$config = $this->createMockedConfiguration();
$config->expects($this->atLeastOnce()) // Changed from once() to atLeastOnce()
->method('getId')
->willReturn(1);
$salesforceUser = [
'Email' => '[EMAIL]',
'UserPreferencesLightningExperiencePreferred' => true,
'CallCenterId' => '123',
'Id' => '456',
'ProfileId' => '789',
'CustomField' => 'CustomValue',
];
$customRules = [
['field' => 'CustomField', 'value' => 'CustomValue'],
];
$this->mockQueryBuilderAndHandler($userToSearch, [$salesforceUser]);
$user = $this->createMock(User::class);
$user->expects($this->once())
->method('getId')
->willReturn(123);
$this->mockTeamRepository($team, $salesforceUser, $user, true, $customRules);
$this->mockProfileRepository($user);
$serviceMock = $this->getServiceMock();
$serviceMock->team = $team;
$serviceMock->config = $config;
$profile = $serviceMock->syncProfiles($userToSearch);
$this->assertNull($profile);
}
public function testSyncProfilesWithCustomValidationFailing(): void
{
$userToSearch = null;
$team = $this->createMock(Team::class);
$config = $this->createMockedConfiguration();
$salesforceUser = [
'Email' => '[EMAIL]',
'UserPreferencesLightningExperiencePreferred' => true,
'CallCenterId' => '123',
'Id' => '456',
'ProfileId' => '789',
'CustomField' => 'WrongValue',
];
$customRules = [
['field' => 'CustomField', 'value' => 'CustomValue'],
];
$this->mockQueryBuilderAndHandler($userToSearch, [$salesforceUser]);
$teamRepository = $this->getMockForAbstractClass(TeamRepository::class, [], '', false, true, true, ['findActiveTeamMemberByEmail', 'getTeamSetting']);
$teamSettings = $this->createMock(TeamSettings::class);
$teamSettings->method('getValueType')
->willReturn('array');
$teamSettings->method('getValue')
->willReturn(json_encode($customRules));
$teamRepository->expects($this->once())
->method('getTeamSetting')
->with($team, 'custom_profile_validation')
->willReturn($teamSettings);
app()->bind(TeamRepository::class, function () use ($teamRepository) {
return $teamRepository;
});
$profileRepository = $this->createMock(ProfileRepository::class);
$profileRepository->expects($this->never())
->method('updateOrCreateProfile');
$this->app->instance(ProfileRepository::class, $profileRepository);
$serviceMock = $this->getServiceMock();
$serviceMock->team = $team;
$serviceMock->config = $config;
$result = $serviceMock->syncProfiles($userToSearch);
$this->assertNull($result);
}
public function testGetContactRolesFromCrm(): void
{
$contactRoles = [
[
'Id' => '1',
'ContactId' => 'Contact1',
'OpportunityId' => 'Opportunity1',
'Opportunity' => ['OwnerId' => 'Owner1'],
'IsPrimary' => true,
'Role' => 'Decision Maker',
],
];
$expectedResponse = [
[
'id' => '1',
'contactId' => 'Contact1',
'opportunityId' => 'Opportunity1',
'ownerId' => 'Owner1',
'isPrimary' => true,
'role' => 'Decision Maker',
],
];
$this->bindQueryIterator($contactRoles);
$serviceMock = $this->getServiceMock();
$data = $serviceMock->getContactRolesFromCrm(now()->subDay());
$this->assertEquals($expectedResponse, $data);
}
public function testGetContactRolesFromCrmNoResult(): void
{
$this->bindQueryIterator([]);
$serviceMock = $this->getServiceMock();
$data = $serviceMock->getContactRolesFromCrm(now()->subDay());
$this->assertEquals([], $data);
}
public function testSyncContactRoles(): void
{
$contactRoles = [
[
'id' => '1',
'contactId' => 'Contact1',
'opportunityId' => 'Opportunity1',
'ownerId' => 'Owner1',
'isPrimary' => true,
'role' => 'Decision Maker',
],
];
app()->bind(ContactRoleRepository::class, function () {
$contactRoleRepository = $this->createMock(ContactRoleRepository::class);
$contactRoleRepository->expects($this->once())
->method('saveContactRoles');
return $contactRoleRepository;
});
$serviceMock = $this->getServiceMock([
'getContactRolesFromCrm',
'syncRemotelyDeletedContactRoles',
'syncContact',
'syncOpportunity',
]);
$config = $this->createMock(Configuration::class);
$hasMany = $this->createMock(HasManyExtended::class);
$hasMany->expects($this->exactly(2))
->method('where')
->willReturn($hasMany);
$hasMany->expects($this->exactly(2))
->method('first')
->willReturn(
$this->createMock(Contact::class),
$this->createMock(Opportunity::class)
);
$config->expects($this->once())
->method('contacts')
->willReturn($hasMany);
$config->expects($this->once())
->method('opportunities')
->willReturn($hasMany);
$serviceMock->config = $config;
$serviceMock->expects($this->once())
->method('getContactRolesFromCrm')
->willReturn($contactRoles);
$serviceMock->expects($this->once())
->method('syncRemotelyDeletedContactRoles');
$data = $serviceMock->syncContactRoles(now()->subDay());
$this->assertEquals(1, $data);
}
public function testSyncRemotelyDeletedContactRoles(): void
{
$contactRoles = [
[
'id' => '1',
'crm_provider_id' => '1',
],
];
app()->bind(QueryHandler::class, function () use ($contactRoles) {
$queryResults = new QueryResults($contactRoles, 1, true, null);
$handler = $this->createMock(QueryHandler::class);
$handler->method('queryDeleted')
->willReturn($queryResults);
return $handler;
});
app()->bind(ContactRoleRepository::class, function () {
$contactRoleRepository = $this->createMock(ContactRoleRepository::class);
$contactRoleRepository->expects($this->once())
->method('deleteContactRoles');
return $contactRoleRepository;
});
$serviceMock = $this->getServiceMock();
$serviceMock->team = $this->createMock(Team::class);
$data = $this->invokePrivateMethod('syncRemotelyDeletedContactRoles', $serviceMock, []);
$this->assertTrue($data);
}
private function bindQueryIterator(array $queryResult): void
{
/** @var Client $client */
$client = $this->createMock(Client::class);
$queryIterator = new QueryIterator(
$client,
new QueryResults($queryResult, 1, true, null)
);
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('query')
->willReturn($queryIterator);
return $handler;
});
}
public static function getOpportunitySortOrderDataProvider(): array
{
return [
'all open recently updated' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED, ['LastModifiedDate DESC', true]],
'all open recently created' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED, ['CreatedDate DESC', true]],
'all open oldest created' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED, ['CreatedDate ASC', true]],
'all recently updated' => [Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED, ['LastModifiedDate DESC', false]],
'default' => ['unknown', ['LastModifiedDate DESC', true]],
];
}
private function createMockedConfiguration(): Configuration
{
$config = $this->createMock(Configuration::class);
$profilesRelation = $this->getMockBuilder(\Illuminate\Database\Eloquent\Relations\HasMany::class)
->disableOriginalConstructor()
->onlyMethods(['get'])
->addMethods(['where', 'first'])
->getMock();
$profilesRelation->method('where')->willReturnSelf();
$profilesRelation->method('get')->willReturn(collect([]));
$profilesRelation->method('first')->willReturn(null);
$config->method('profiles')->willReturn($profilesRelation);
return $config;
}
private function getServiceMock(array $onlyMethods = []): MockObject&Service
{
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$this->createMock(Client::class),
$this->createMock(PayloadBuilder::class),
$this->createMock(Dispatcher::class),
$this->createMock(CountriesMap::class),
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods($onlyMethods)
->getMock();
$serviceMock->profile = $this->createMock(Profile::class);
return $serviceMock;
}
private function mockQueryBuilderAndHandler($userToSearch, $salesforceUsers): void
{
app()->bind(QueryBuilder::class, function () use ($userToSearch) {
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->expects($this->once())
->method('buildGetUsersQuery')
->with($userToSearch)
->willReturn('SELECT * FROM Users');
return $queryBuilder;
});
$queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults($salesforceUsers, count($salesforceUsers), true, null));
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('query')
->willReturn($queryIterator);
return $handler;
});
}
private function mockTeamRepository(
Team $team,
array $salesforceUser,
?User $user = null,
bool $userSearch = true,
array $customRules = []
): void {
$teamRepository = $this->getMockForAbstractClass(TeamRepository::class, [], '', false, true, true, ['findActiveTeamMemberByEmail', 'getTeamSetting']);
if ($userSearch) {
$teamRepository->expects($this->once())
->method('findActiveTeamMemberByEmail')
->with($team, $salesforceUser['Email'])
->willReturn($user);
}
$teamSettings = $this->createMock(TeamSettings::class);
$teamSettings->method('getValueType')
->willReturn('array');
$teamSettings->method('getValue')
->willReturn(json_encode($customRules));
$teamRepository->expects($this->once())
->method('getTeamSetting')
->with($team, 'custom_profile_validation')
->willReturn($teamSettings);
app()->bind(TeamRepository::class, function () use ($teamRepository) {
return $teamRepository;
});
}
private function mockProfileRepository(User $user): void
{
$profileRepository = $this->createMock(ProfileRepository::class);
$profileRepository->expects($this->once())
->method('updateOrCreateProfile')
->with(
$user,
[
'crm_configuration_id' => 1,
'crm_provider_id' => '456',
],
[
'user_id' => 123,
'edition' => Profile::EDITION_LIGHTNING,
'has_external_cti' => true,
'crm_profile_id' => '789',
]
)
->willReturn(new Profile());
$this->app->instance(ProfileRepository::class, $profileRepository);
}
public function testBuildEnhancedNoteDecodesHtmlEntities(): void
{
$service = $this->getServiceMock(['createRecord']);
$profile = new Profile();
$profile->setAttribute('crm_provider_id', 'owner-123');
$service->profile = $profile;
$service->expects($this->exactly(2))
->method('createRecord')
->willReturnOnConsecutiveCalls('note-id-123', 'link-id-456');
$bodyWithEntities = 'Welch's current challenges and Facebook's Club';
$result = $this->invokePrivateMethod('buildEnhancedNote', $service, [
'Test Title',
$bodyWithEntities,
'object-id-789',
]);
$this->assertEquals('note-id-123', $result);
}
public function testBuildEnhancedNoteSanitizesWithoutQuotes(): void
{
$service = $this->getServiceMock(['createRecord']);
$profile = new Profile();
$profile->setAttribute('crm_provider_id', 'owner-456');
$service->profile = $profile;
$service->expects($this->exactly(2))
->method('createRecord')
->willReturnCallback(function ($type, $data) {
if ($type === 'ContentNote') {
$decoded = base64_decode($data['Content']);
$this->assertStringContainsString("Welch's", $decoded);
$this->assertStringNotContainsString(''', $decoded);
$this->assertStringNotContainsString('&#039;', $decoded);
$this->assertStringContainsString('<script>', $decoded);
return 'note-id-456';
}
return 'link-id-789';
});
$bodyWithMixedContent = "Welch's and <script>alert('xss')</script>";
$result = $this->invokePrivateMethod('buildEnhancedNote', $service, [
'Test Title',
$bodyWithMixedContent,
'object-id-123',
]);
$this->assertEquals('note-id-456', $result);
}
public function testBuildEnhancedNoteConvertsLineBreaks(): void
{
$service = $this->getServiceMock(['createRecord']);
$profile = new Profile();
$profile->setAttribute('crm_provider_id', 'owner-789');
$service->profile = $profile;
$service->expects($this->exactly(2))
->method('createRecord')
->willReturnCallback(function ($type, $data) {
if ($type === 'ContentNote') {
$decoded = base64_decode($data['Content']);
$this->assertStringContainsString('<br>', $decoded);
$this->assertStringNotContainsString('<br />', $decoded);
return 'note-id-789';
}
return 'link-id-012';
});
$bodyWithLineBreaks = "Line 1\nLine 2\nLine 3";
$result = $this->invokePrivateMethod('buildEnhancedNote', $service, [
'Test Title',
$bodyWithLineBreaks,
'object-id-456',
]);
$this->assertEquals('note-id-789', $result);
}
public function testBuildEnhancedNoteHandlesComplexScenario(): void
{
$service = $this->getServiceMock(['createRecord']);
$profile = new Profile();
$profile->setAttribute('crm_provider_id', 'owner-complex');
$service->profile = $profile;
$service->expects($this->exactly(2))
->method('createRecord')
->willReturnCallback(function ($type, $data) {
if ($type === 'ContentNote') {
$decoded = base64_decode($data['Content']);
$this->assertStringContainsString("Welch's", $decoded);
$this->assertStringContainsString("Facebook's Club", $decoded);
$this->assertStringContainsString("Arctics'", $decoded);
$this->assertStringNotContainsString(''', $decoded);
$this->assertStringNotContainsString('&#039;', $decoded);
$this->assertStringContainsString('<br>', $decoded);
$this->assertStringContainsString('<', $decoded);
$this->assertStringContainsString('>', $decoded);
return 'note-complex';
}
return 'link-complex';
});
$complexBody = "Summary:\n---------\nThe call focused on understanding Welch's current challenges and exploring how Arctics' Revenue Growth Management solutions could support their strategic goals.\n\n• John SMith discussed his role as a category advisor for Google and Facebook's Club, emphasizing the importance of market research and advising on product assortment.\n• Madona introduced Arctics' Virtual Shoppers AI, which simulates consumer <behavior> to optimize pricing and promotional strategies.";
$result = $this->invokePrivateMethod('buildEnhancedNote', $service, [
'Jiminny Transcription Summary',
$complexBody,
'task-id-001',
]);
$this->assertEquals('note-complex', $result);
}
public function testSyncRemotelyDeletedObjectsWithErrorHandlingSuccess(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$prospectPhotoPathService,
])
->onlyMethods(['syncRemotelyDeletedObjects'])
->getMock();
// Mock team
$team = $this->createMock(Team::class);
$team->method('getUuid')->willReturn('team-uuid-123');
$serviceMock->team = $team;
// Expect syncRemotelyDeletedObjects to be called once and succeed
$serviceMock->expects($this->once())
->method('syncRemotelyDeletedObjects')
->with(\Jiminny\Enums\CrmObject::ACCOUNT);
// Call the protected method using reflection
$reflection = new \ReflectionClass($serviceMock);
$method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');
$method->setAccessible(true);
// Should not throw any exceptions
$method->invoke($serviceMock, \Jiminny\Enums\CrmObject::ACCOUNT);
$this->assertTrue(true); // Test completed successfully
}
public function testSyncRemotelyDeletedObjectsWithErrorHandlingFailure(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$prospectPhotoPathService,
])
->onlyMethods(['syncRemotelyDeletedObjects'])
->getMock();
// Mock team
$team = $this->createMock(Team::class);
$team->method('getUuid')->willReturn('team-uuid-456');
$serviceMock->team = $team;
// Mock logger to verify warning is logged
$logger = $this->createMock(\Psr\Log\LoggerInterface::class);
// Use reflection to set the protected logger property
$reflection = new \ReflectionClass($serviceMock);
$loggerProperty = $reflection->getProperty('logger');
$loggerProperty->setAccessible(true);
$loggerProperty->setValue($serviceMock, $logger);
$exception = new \Exception('Sync failed due to API error');
// Expect syncRemotelyDeletedObjects to throw an exception
$serviceMock->expects($this->once())
->method('syncRemotelyDeletedObjects')
->with(\Jiminny\Enums\CrmObject::CONTACT)
->willThrowException($exception);
// Expect warning to be logged with correct message and parameters
$logger->expects($this->once())
->method('warning')
->with(
'[Salesforce] Remotely deleted objects sync failed',
[
'objectType' => 'contact',
'teamId' => 'team-uuid-456',
'reason' => 'Sync failed due to API error',
]
);
// Call the protected method using reflection
$reflection = new \ReflectionClass($serviceMock);
$method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');
$method->setAccessible(true);
// Should not re-throw the exception, just log it
$method->invoke($serviceMock, \Jiminny\Enums\CrmObject::CONTACT);
$this->assertTrue(true); // Test completed successfully
}
public function testSyncRemotelyDeletedObjectsWithErrorHandlingWithLogParams(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$prospectPhotoPathService,
])
->onlyMethods(['syncRemotelyDeletedObjects'])
->getMock();
// Mock team
$team = $this->createMock(Team::class);
$team->method('getUuid')->willReturn('team-uuid-789');
$serviceMock->team = $team;
// Mock logger to verify warning is logged
$logger = $this->createMock(\Psr\Log\LoggerInterface::class);
// Use reflection to set the protected logger property
$loggerReflection = new \ReflectionClass($serviceMock);
$loggerProperty = $loggerReflection->getProperty('logger');
$loggerProperty->setAccessible(true);
$loggerProperty->setValue($serviceMock, $logger);
$exception = new \Exception('Network timeout');
// Expect syncRemotelyDeletedObjects to throw an exception
$serviceMock->expects($this->once())
->method('syncRemotelyDeletedObjects')
->with(\Jiminny\Enums\CrmObject::OPPORTUNITY)
->willThrowException($exception);
// Additional log parameters
$logParams = [
'syncType' => 'full',
'batchSize' => 100,
];
// Expect warning to be logged with merged parameters
$logger->expects($this->once())
->method('warning')
->with(
'[Salesforce] Remotely deleted objects sync failed',
[
'objectType' => 'opportunity',
'teamId' => 'team-uuid-789',
'reason' => 'Network timeout',
'syncType' => 'full',
'batchSize' => 100,
]
);
// Call the protected method using reflection
$reflection = new \ReflectionClass($serviceMock);
$method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');
$method->setAccessible(true);...
|
[{"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-20963-fix-import-on-deleted-entity, menu","depth":5,"on_screen":true,"help_text":"Git Branch: JY-20963-fix-import-on-deleted-entity","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"ServiceTest","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'ServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'ServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Built-in Preview","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":"Chrome","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":"Firefox","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":"Safari","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":"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":"4","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"32","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"168","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"28","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Services\\Crm\\Salesforce;\n\nuse Illuminate\\Database\\Eloquent\\Relations\\HasMany;\nuse Illuminate\\Events\\Dispatcher;\nuse Illuminate\\Support\\Facades\\Cache;\nuse Jiminny\\Component\\Country\\CountriesMap;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Enums\\CrmObject;\nuse Jiminny\\Exceptions\\InvalidArgumentException;\nuse Jiminny\\Exceptions\\NoResultsException;\nuse Jiminny\\Integrations\\PlaybookResolver;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Crm\\Configuration;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\TeamSettings;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\ContactRoleRepository;\nuse Jiminny\\Repositories\\Crm\\FieldDataRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\Salesforce\\Client;\nuse Jiminny\\Services\\Crm\\Salesforce\\PayloadBuilder;\nuse Jiminny\\Services\\Crm\\Salesforce\\QueryBuilder;\nuse Jiminny\\Services\\Crm\\Salesforce\\QueryHandler;\nuse Jiminny\\Services\\Crm\\Salesforce\\QueryIterator;\nuse Jiminny\\Services\\Crm\\Salesforce\\QueryResults;\nuse Jiminny\\Services\\Crm\\Salesforce\\Service;\nuse PHPUnit\\Framework\\MockObject\\MockObject;\nuse Tests\\TestCase;\nuse Tests\\Unit\\Traits\\TestPrivateMethod;\n\nclass ServiceTest extends TestCase\n{\n use TestPrivateMethod;\n\n public function testFetchAndAssociateRelatedActivity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $payloadBuilder->method('addCustomLogicFieldsPayload')\n ->willReturnCallback(function ($activity, $payload) {\n return $payload;\n });\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods(['fetchRelatedActivity', 'getPlaybook', 'getPlaybookCategory', 'updateRecord'])\n ->getMock();\n\n $serviceMock->expects($this->once())\n ->method('fetchRelatedActivity')\n ->willReturn([\n 'Id' => 'testId',\n 'Type' => null,\n 'OwnerId' => 'testerUser',\n 'Description' => 'Test description',\n ]);\n\n $user = $this->createMock(User::class);\n $team = $this->createMock(Team::class);\n $user->method('getAttribute')->with('team')->willReturn($team);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityField')->willReturn(null);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_EVENT);\n\n $serviceMock->expects($this->once())\n ->method('getPlaybook')\n ->with($user)\n ->willReturn($playbook);\n\n $serviceMock->expects($this->never())\n ->method('getPlaybookCategory');\n\n $serviceMock->expects($this->never())\n ->method('updateRecord');\n\n $fieldDataRepository = $this->createMock(FieldDataRepository::class);\n $fieldDataRepository->method('getActivityFieldData')->willReturn(collect([]));\n app()->instance(FieldDataRepository::class, $fieldDataRepository);\n\n $config = $this->createMock(Configuration::class);\n $profilesRelation = $this->getMockBuilder(\\Illuminate\\Database\\Eloquent\\Relations\\HasMany::class)\n ->disableOriginalConstructor()\n ->onlyMethods(['get'])\n ->addMethods(['where'])\n ->getMock();\n $profilesRelation->method('where')->willReturnSelf();\n $profilesRelation->method('get')->willReturn(collect([]));\n $config->method('profiles')->willReturn($profilesRelation);\n\n $serviceMock->config = $config;\n $serviceMock->profile = null;\n\n $actualStartTime = \\Carbon\\Carbon::now();\n\n $activity = $this->getMockBuilder(Activity::class)\n ->disableOriginalConstructor()\n ->onlyMethods(['update', 'hasProspect'])\n ->getMock();\n\n $activity->method('update')->willReturn(true);\n $activity->method('hasProspect')->willReturn(true);\n\n $activity->type = Activity::TYPE_CONFERENCE;\n $activity->provider = Activity::PROVIDER_TWILIO;\n $activity->lead_id = 1;\n $activity->user_id = 0;\n $activity->id_string = 'test-activity-id';\n $activity->user = $user;\n\n $activity->actual_start_time = $actualStartTime;\n $activity->uuid = 'c53d8320-f556-4cee-a2f8-5f232f454ca4';\n\n app()->bind(PlaybookResolver::class, function () use ($user) {\n $playbook = $this->createMock(Playbook::class);\n $playbookResolver = $this->createMock(PlaybookResolver::class);\n $playbookResolver->expects($this->once())\n ->method('resolvePlaybookByUser')\n ->with($user)\n ->willReturn($playbook);\n\n return $playbookResolver;\n });\n\n $data = $serviceMock->fetchAndAssociateRelatedActivity($activity);\n\n $this->assertInstanceOf(Activity::class, $data);\n $this->assertEquals(Activity::TYPE_CONFERENCE, $data->getType());\n $this->assertEquals($actualStartTime->getTimestamp(), $data->getActualStartTime()->getTimestamp());\n }\n\n public function testFetchAndAssociateRelatedActivitySkipsForTaskBasedPlaybook(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods(['fetchRelatedActivity', 'getPlaybook'])\n ->getMock();\n\n $user = $this->createMock(User::class);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n $playbook->method('getId')->willReturn(123);\n\n $serviceMock->expects($this->once())\n ->method('getPlaybook')\n ->with($user)\n ->willReturn($playbook);\n\n $serviceMock->expects($this->never())\n ->method('fetchRelatedActivity');\n\n $activity = $this->getMockBuilder(Activity::class)\n ->disableOriginalConstructor()\n ->onlyMethods(['hasProspect', 'getUuid'])\n ->getMock();\n\n $activity->method('hasProspect')->willReturn(true);\n $activity->method('getUuid')->willReturn('c53d8320-f556-4cee-a2f8-5f232f454ca4');\n $activity->type = Activity::TYPE_CONFERENCE;\n $activity->actual_start_time = \\Carbon\\Carbon::now();\n $activity->user = $user;\n\n $result = $serviceMock->fetchAndAssociateRelatedActivity($activity);\n\n $this->assertNull($result);\n }\n\n public function testFetchAndAssociateRelatedActivityReturnsNullForNonConference(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $serviceMock = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)\n );\n\n $activity = $this->getMockBuilder(Activity::class)\n ->disableOriginalConstructor()\n ->getMock();\n\n $activity->type = Activity::TYPE_SOFTPHONE;\n\n $result = $serviceMock->fetchAndAssociateRelatedActivity($activity);\n\n $this->assertNull($result);\n }\n\n public function testFetchAndAssociateRelatedActivityReturnsNullWhenNoStartTime(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $serviceMock = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)\n );\n\n\n $activity = $this->getMockBuilder(Activity::class)\n ->disableOriginalConstructor()\n ->getMock();\n\n $activity->type = Activity::TYPE_CONFERENCE;\n $activity->actual_start_time = null;\n $activity->scheduled_start_time = null;\n\n $result = $serviceMock->fetchAndAssociateRelatedActivity($activity);\n\n $this->assertNull($result);\n }\n\n public function testFetchAndAssociateRelatedActivityReturnsNullWhenNoProspect(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods(['getPlaybook'])\n ->getMock();\n\n $serviceMock->expects($this->never())\n ->method('getPlaybook');\n\n $activity = $this->getMockBuilder(Activity::class)\n ->disableOriginalConstructor()\n ->onlyMethods(['hasProspect', 'getUuid'])\n ->getMock();\n\n $activity->method('hasProspect')->willReturn(false);\n $activity->method('getUuid')->willReturn('c53d8320-f556-4cee-a2f8-5f232f454ca4');\n $activity->type = Activity::TYPE_CONFERENCE;\n $activity->actual_start_time = \\Carbon\\Carbon::now();\n\n $result = $serviceMock->fetchAndAssociateRelatedActivity($activity);\n\n $this->assertNull($result);\n }\n\n public function testMatchExactlyByEmail(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods([])\n ->getMock();\n\n $profile = new Profile();\n $profile->setAttribute('id', bin2hex(random_bytes(8)));\n $serviceMock->profile = $profile;\n\n $team = $this->createMock(Team::class);\n $serviceMock->team = $team;\n\n $data = $serviceMock->matchExactlyByEmail(bin2hex(random_bytes(8)) . 'test_email@testserver.com');\n\n $this->assertEquals(null, $data);\n }\n\n public function testMatchDomainFromEmail(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $queryIterator = $this->createMock(QueryIterator::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $config = $this->createMock(Configuration::class);\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('search')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods(['convertCrmData'])\n ->getMock();\n\n $profile = new Profile();\n $profile->account_fields = 'Field1, Field2, Field3';\n $serviceMock->profile = $profile;\n\n $serviceMock->expects($this->once())\n ->method('convertCrmData')\n ->willReturn(['test']);\n\n $this->app->bind(QueryBuilder::class, function () {\n $queryBuilder = $this->createMock(QueryBuilder::class);\n $queryBuilder->expects($this->once())\n ->method('buildMatchByDomainQuery')\n ->with('test_email@testserver.com')\n ->willReturn('FIND {test_email@testserver.com} IN ALL FIELDS RETURNING Account(Id)');\n\n return $queryBuilder;\n });\n\n $team = $this->createMock(Team::class);\n\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n\n $data = $serviceMock->matchByDomain('test_email@testserver.com');\n\n $this->assertEquals(['test'], $data);\n }\n\n public function testBuildTaskSearchFields(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)\n );\n\n $fields = $service->buildTaskSearchFields();\n\n $expectedFields = ['Id', 'WhoId', 'WhatId', 'AccountId'];\n\n $this->assertEquals($expectedFields, $fields);\n }\n\n public function testMapCrmObjects(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class),\n );\n\n $sampleTask = [\n 'WhoId' => '003sampleWhoId',\n 'AccountId' => 'sampleAccountId',\n 'WhatId' => 'sampleWhatId',\n ];\n\n $activityData = $service->mapCrmObjects($sampleTask);\n\n $expectedActivityData = [\n 'contact' => '003sampleWhoId',\n 'account' => 'sampleAccountId',\n 'opportunity' => 'sampleWhatId',\n ];\n\n $this->assertEquals($expectedActivityData, $activityData);\n }\n\n public function testGetInstalledAppVersion(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n $queryIterator = $this->createMock(QueryIterator::class);\n $queryIterator->expects($this->any())\n ->method('current')->willReturn([\n 'SubscriberPackageVersion' => [\n 'MajorVersion' => '1',\n 'MinorVersion' => '0',\n 'PatchVersion' => '1',\n 'BuildNumber' => '0',\n ],\n ]);\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('metadata')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods(array_diff(get_class_methods(Service::class), ['getInstalledAppVersion']))\n ->getMock();\n\n $version = $serviceMock->getInstalledAppVersion();\n\n $this->assertEquals('1010', $version);\n }\n\n public function testSyncProfiles(): void\n {\n $userToSearch = null;\n\n $team = $this->createMock(Team::class);\n $config = $this->createMockedConfiguration();\n $config->expects($this->once())\n ->method('getId')\n ->willReturn(1);\n\n $salesforceUser = [\n 'Email' => 'test@example.com',\n 'UserPreferencesLightningExperiencePreferred' => true,\n 'CallCenterId' => '123',\n 'Id' => '456',\n 'ProfileId' => '789',\n ];\n\n app()->bind(QueryBuilder::class, function () use ($userToSearch) {\n $queryBuilder = $this->createMock(QueryBuilder::class);\n $queryBuilder->expects($this->once())\n ->method('buildGetUsersQuery')\n ->with($userToSearch)\n ->willReturn('SELECT * FROM Users');\n\n return $queryBuilder;\n });\n\n $queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('query')\n ->with('SELECT * FROM Users')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n\n $user = $this->createMock(User::class);\n $user->expects($this->once())\n ->method('getId')\n ->willReturn(123);\n\n $this->mockTeamRepository($team, $salesforceUser, $user);\n\n $profileRepository = $this->createMock(ProfileRepository::class);\n $profileRepository->expects($this->once())\n ->method('updateOrCreateProfile')\n ->with(\n $user,\n [\n 'crm_configuration_id' => 1,\n 'crm_provider_id' => '456',\n ],\n [\n 'user_id' => 123,\n 'edition' => Profile::EDITION_LIGHTNING,\n 'has_external_cti' => true,\n 'crm_profile_id' => '789',\n ]\n )\n ->willReturn(new Profile());\n\n $this->app->instance(ProfileRepository::class, $profileRepository);\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n $result = $serviceMock->syncProfiles($userToSearch);\n\n $this->assertNull($result);\n }\n\n public function testSyncProfilesEmailIsNull(): void\n {\n $userToSearch = $this->createMock(User::class);\n\n $salesforceUser = [\n 'Email' => null,\n ];\n\n app()->bind(QueryBuilder::class, function () {\n $queryBuilder = $this->createMock(QueryBuilder::class);\n $queryBuilder->expects($this->once())\n ->method('buildGetUsersQuery')\n ->with(null)\n ->willReturn('SELECT * FROM Users');\n\n return $queryBuilder;\n });\n\n $queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->exactly(2))\n ->method('query')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n\n $team = $this->createMock(Team::class);\n $user = $this->createMock(User::class);\n $this->mockTeamRepository($team, $salesforceUser, $user, false);\n\n $config = $this->createMock(Configuration::class);\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n\n $profile = $serviceMock->syncProfiles(null);\n\n $this->assertNull($profile);\n }\n\n public function testSyncProfilesUserToSearchMatchesCurrentUser(): void\n {\n $userToSearch = $this->createMock(User::class);\n $userToSearch->expects($this->once())\n ->method('getId')\n ->willReturn(123);\n\n $team = $this->createMock(Team::class);\n $config = $this->createMock(Configuration::class);\n $config->expects($this->once())\n ->method('getId')\n ->willReturn(1);\n\n $salesforceUser = [\n 'Email' => 'test@example.com',\n 'UserPreferencesLightningExperiencePreferred' => true,\n 'CallCenterId' => '123',\n 'Id' => '456',\n 'ProfileId' => '789',\n ];\n\n app()->bind(QueryBuilder::class, function () use ($userToSearch) {\n $queryBuilder = $this->createMock(QueryBuilder::class);\n $queryBuilder->expects($this->once())\n ->method('buildGetUsersQuery')\n ->with($userToSearch)\n ->willReturn('SELECT * FROM Users');\n\n return $queryBuilder;\n });\n\n $queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('query')\n ->with('SELECT * FROM Users')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n\n $user = $this->createMock(User::class);\n $user->expects($this->exactly(2))\n ->method('getId')\n ->willReturn(123);\n\n $this->mockTeamRepository($team, $salesforceUser, $user);\n\n $profileRepository = $this->createMock(ProfileRepository::class);\n $profileRepository->expects($this->once())\n ->method('updateOrCreateProfile')\n ->with(\n $user,\n [\n 'crm_configuration_id' => 1,\n 'crm_provider_id' => '456',\n ],\n [\n 'user_id' => 123,\n 'edition' => Profile::EDITION_LIGHTNING,\n 'has_external_cti' => true,\n 'crm_profile_id' => '789',\n ]\n )\n ->willReturn(new Profile());\n\n $this->app->instance(ProfileRepository::class, $profileRepository);\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n $profile = $serviceMock->syncProfiles($userToSearch);\n\n $this->assertInstanceOf(Profile::class, $profile);\n }\n\n public function testSyncProfilesWithCustomValidation(): void\n {\n $userToSearch = null;\n\n $team = $this->createMock(Team::class);\n $config = $this->createMockedConfiguration();\n $config->expects($this->atLeastOnce()) // Changed from once() to atLeastOnce()\n ->method('getId')\n ->willReturn(1);\n\n $salesforceUser = [\n 'Email' => 'test@example.com',\n 'UserPreferencesLightningExperiencePreferred' => true,\n 'CallCenterId' => '123',\n 'Id' => '456',\n 'ProfileId' => '789',\n 'CustomField' => 'CustomValue',\n ];\n\n $customRules = [\n ['field' => 'CustomField', 'value' => 'CustomValue'],\n ];\n\n $this->mockQueryBuilderAndHandler($userToSearch, [$salesforceUser]);\n\n $user = $this->createMock(User::class);\n $user->expects($this->once())\n ->method('getId')\n ->willReturn(123);\n\n $this->mockTeamRepository($team, $salesforceUser, $user, true, $customRules);\n\n $this->mockProfileRepository($user);\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n $profile = $serviceMock->syncProfiles($userToSearch);\n\n $this->assertNull($profile);\n }\n\n public function testSyncProfilesWithCustomValidationFailing(): void\n {\n $userToSearch = null;\n\n $team = $this->createMock(Team::class);\n $config = $this->createMockedConfiguration();\n\n $salesforceUser = [\n 'Email' => 'test@example.com',\n 'UserPreferencesLightningExperiencePreferred' => true,\n 'CallCenterId' => '123',\n 'Id' => '456',\n 'ProfileId' => '789',\n 'CustomField' => 'WrongValue',\n ];\n\n $customRules = [\n ['field' => 'CustomField', 'value' => 'CustomValue'],\n ];\n\n $this->mockQueryBuilderAndHandler($userToSearch, [$salesforceUser]);\n\n $teamRepository = $this->getMockForAbstractClass(TeamRepository::class, [], '', false, true, true, ['findActiveTeamMemberByEmail', 'getTeamSetting']);\n\n $teamSettings = $this->createMock(TeamSettings::class);\n $teamSettings->method('getValueType')\n ->willReturn('array');\n\n $teamSettings->method('getValue')\n ->willReturn(json_encode($customRules));\n\n $teamRepository->expects($this->once())\n ->method('getTeamSetting')\n ->with($team, 'custom_profile_validation')\n ->willReturn($teamSettings);\n\n app()->bind(TeamRepository::class, function () use ($teamRepository) {\n return $teamRepository;\n });\n\n $profileRepository = $this->createMock(ProfileRepository::class);\n $profileRepository->expects($this->never())\n ->method('updateOrCreateProfile');\n\n $this->app->instance(ProfileRepository::class, $profileRepository);\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n $result = $serviceMock->syncProfiles($userToSearch);\n\n $this->assertNull($result);\n }\n\n public function testGetContactRolesFromCrm(): void\n {\n $contactRoles = [\n [\n 'Id' => '1',\n 'ContactId' => 'Contact1',\n 'OpportunityId' => 'Opportunity1',\n 'Opportunity' => ['OwnerId' => 'Owner1'],\n 'IsPrimary' => true,\n 'Role' => 'Decision Maker',\n ],\n ];\n\n $expectedResponse = [\n [\n 'id' => '1',\n 'contactId' => 'Contact1',\n 'opportunityId' => 'Opportunity1',\n 'ownerId' => 'Owner1',\n 'isPrimary' => true,\n 'role' => 'Decision Maker',\n ],\n ];\n\n $this->bindQueryIterator($contactRoles);\n\n $serviceMock = $this->getServiceMock();\n\n $data = $serviceMock->getContactRolesFromCrm(now()->subDay());\n\n $this->assertEquals($expectedResponse, $data);\n }\n\n public function testGetContactRolesFromCrmNoResult(): void\n {\n $this->bindQueryIterator([]);\n\n $serviceMock = $this->getServiceMock();\n\n $data = $serviceMock->getContactRolesFromCrm(now()->subDay());\n\n $this->assertEquals([], $data);\n }\n\n public function testSyncContactRoles(): void\n {\n $contactRoles = [\n [\n 'id' => '1',\n 'contactId' => 'Contact1',\n 'opportunityId' => 'Opportunity1',\n 'ownerId' => 'Owner1',\n 'isPrimary' => true,\n 'role' => 'Decision Maker',\n ],\n ];\n\n app()->bind(ContactRoleRepository::class, function () {\n $contactRoleRepository = $this->createMock(ContactRoleRepository::class);\n $contactRoleRepository->expects($this->once())\n ->method('saveContactRoles');\n\n return $contactRoleRepository;\n });\n\n $serviceMock = $this->getServiceMock([\n 'getContactRolesFromCrm',\n 'syncRemotelyDeletedContactRoles',\n 'syncContact',\n 'syncOpportunity',\n ]);\n\n $config = $this->createMock(Configuration::class);\n $hasMany = $this->createMock(HasManyExtended::class);\n $hasMany->expects($this->exactly(2))\n ->method('where')\n ->willReturn($hasMany);\n\n $hasMany->expects($this->exactly(2))\n ->method('first')\n ->willReturn(\n $this->createMock(Contact::class),\n $this->createMock(Opportunity::class)\n );\n\n $config->expects($this->once())\n ->method('contacts')\n ->willReturn($hasMany);\n $config->expects($this->once())\n ->method('opportunities')\n ->willReturn($hasMany);\n\n $serviceMock->config = $config;\n\n $serviceMock->expects($this->once())\n ->method('getContactRolesFromCrm')\n ->willReturn($contactRoles);\n\n $serviceMock->expects($this->once())\n ->method('syncRemotelyDeletedContactRoles');\n\n $data = $serviceMock->syncContactRoles(now()->subDay());\n\n $this->assertEquals(1, $data);\n }\n\n public function testSyncRemotelyDeletedContactRoles(): void\n {\n $contactRoles = [\n [\n 'id' => '1',\n 'crm_provider_id' => '1',\n ],\n ];\n\n app()->bind(QueryHandler::class, function () use ($contactRoles) {\n $queryResults = new QueryResults($contactRoles, 1, true, null);\n\n $handler = $this->createMock(QueryHandler::class);\n $handler->method('queryDeleted')\n ->willReturn($queryResults);\n\n return $handler;\n });\n\n app()->bind(ContactRoleRepository::class, function () {\n $contactRoleRepository = $this->createMock(ContactRoleRepository::class);\n $contactRoleRepository->expects($this->once())\n ->method('deleteContactRoles');\n\n return $contactRoleRepository;\n });\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $this->createMock(Team::class);\n\n $data = $this->invokePrivateMethod('syncRemotelyDeletedContactRoles', $serviceMock, []);\n\n $this->assertTrue($data);\n }\n\n private function bindQueryIterator(array $queryResult): void\n {\n /** @var Client $client */\n $client = $this->createMock(Client::class);\n $queryIterator = new QueryIterator(\n $client,\n new QueryResults($queryResult, 1, true, null)\n );\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('query')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n }\n\n public static function getOpportunitySortOrderDataProvider(): array\n {\n return [\n 'all open recently updated' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED, ['LastModifiedDate DESC', true]],\n 'all open recently created' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED, ['CreatedDate DESC', true]],\n 'all open oldest created' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED, ['CreatedDate ASC', true]],\n 'all recently updated' => [Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED, ['LastModifiedDate DESC', false]],\n 'default' => ['unknown', ['LastModifiedDate DESC', true]],\n ];\n }\n\n private function createMockedConfiguration(): Configuration\n {\n $config = $this->createMock(Configuration::class);\n $profilesRelation = $this->getMockBuilder(\\Illuminate\\Database\\Eloquent\\Relations\\HasMany::class)\n ->disableOriginalConstructor()\n ->onlyMethods(['get'])\n ->addMethods(['where', 'first'])\n ->getMock();\n $profilesRelation->method('where')->willReturnSelf();\n $profilesRelation->method('get')->willReturn(collect([]));\n $profilesRelation->method('first')->willReturn(null);\n $config->method('profiles')->willReturn($profilesRelation);\n\n return $config;\n }\n\n private function getServiceMock(array $onlyMethods = []): MockObject&Service\n {\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $this->createMock(Client::class),\n $this->createMock(PayloadBuilder::class),\n $this->createMock(Dispatcher::class),\n $this->createMock(CountriesMap::class),\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods($onlyMethods)\n ->getMock();\n\n $serviceMock->profile = $this->createMock(Profile::class);\n\n return $serviceMock;\n }\n\n private function mockQueryBuilderAndHandler($userToSearch, $salesforceUsers): void\n {\n app()->bind(QueryBuilder::class, function () use ($userToSearch) {\n $queryBuilder = $this->createMock(QueryBuilder::class);\n $queryBuilder->expects($this->once())\n ->method('buildGetUsersQuery')\n ->with($userToSearch)\n ->willReturn('SELECT * FROM Users');\n\n return $queryBuilder;\n });\n\n $queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults($salesforceUsers, count($salesforceUsers), true, null));\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('query')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n }\n\n private function mockTeamRepository(\n Team $team,\n array $salesforceUser,\n ?User $user = null,\n bool $userSearch = true,\n array $customRules = []\n ): void {\n $teamRepository = $this->getMockForAbstractClass(TeamRepository::class, [], '', false, true, true, ['findActiveTeamMemberByEmail', 'getTeamSetting']);\n\n if ($userSearch) {\n $teamRepository->expects($this->once())\n ->method('findActiveTeamMemberByEmail')\n ->with($team, $salesforceUser['Email'])\n ->willReturn($user);\n }\n\n $teamSettings = $this->createMock(TeamSettings::class);\n $teamSettings->method('getValueType')\n ->willReturn('array');\n\n $teamSettings->method('getValue')\n ->willReturn(json_encode($customRules));\n\n $teamRepository->expects($this->once())\n ->method('getTeamSetting')\n ->with($team, 'custom_profile_validation')\n ->willReturn($teamSettings);\n\n app()->bind(TeamRepository::class, function () use ($teamRepository) {\n return $teamRepository;\n });\n }\n\n private function mockProfileRepository(User $user): void\n {\n $profileRepository = $this->createMock(ProfileRepository::class);\n $profileRepository->expects($this->once())\n ->method('updateOrCreateProfile')\n ->with(\n $user,\n [\n 'crm_configuration_id' => 1,\n 'crm_provider_id' => '456',\n ],\n [\n 'user_id' => 123,\n 'edition' => Profile::EDITION_LIGHTNING,\n 'has_external_cti' => true,\n 'crm_profile_id' => '789',\n ]\n )\n ->willReturn(new Profile());\n\n $this->app->instance(ProfileRepository::class, $profileRepository);\n }\n\n public function testBuildEnhancedNoteDecodesHtmlEntities(): void\n {\n $service = $this->getServiceMock(['createRecord']);\n\n $profile = new Profile();\n $profile->setAttribute('crm_provider_id', 'owner-123');\n $service->profile = $profile;\n\n $service->expects($this->exactly(2))\n ->method('createRecord')\n ->willReturnOnConsecutiveCalls('note-id-123', 'link-id-456');\n\n $bodyWithEntities = 'Welch's current challenges and Facebook's Club';\n\n $result = $this->invokePrivateMethod('buildEnhancedNote', $service, [\n 'Test Title',\n $bodyWithEntities,\n 'object-id-789',\n ]);\n\n $this->assertEquals('note-id-123', $result);\n }\n\n public function testBuildEnhancedNoteSanitizesWithoutQuotes(): void\n {\n $service = $this->getServiceMock(['createRecord']);\n\n $profile = new Profile();\n $profile->setAttribute('crm_provider_id', 'owner-456');\n $service->profile = $profile;\n\n $service->expects($this->exactly(2))\n ->method('createRecord')\n ->willReturnCallback(function ($type, $data) {\n if ($type === 'ContentNote') {\n $decoded = base64_decode($data['Content']);\n $this->assertStringContainsString(\"Welch's\", $decoded);\n $this->assertStringNotContainsString(''', $decoded);\n $this->assertStringNotContainsString('&#039;', $decoded);\n $this->assertStringContainsString('<script>', $decoded);\n\n return 'note-id-456';\n }\n\n return 'link-id-789';\n });\n\n $bodyWithMixedContent = \"Welch's and <script>alert('xss')</script>\";\n\n $result = $this->invokePrivateMethod('buildEnhancedNote', $service, [\n 'Test Title',\n $bodyWithMixedContent,\n 'object-id-123',\n ]);\n\n $this->assertEquals('note-id-456', $result);\n }\n\n public function testBuildEnhancedNoteConvertsLineBreaks(): void\n {\n $service = $this->getServiceMock(['createRecord']);\n\n $profile = new Profile();\n $profile->setAttribute('crm_provider_id', 'owner-789');\n $service->profile = $profile;\n\n $service->expects($this->exactly(2))\n ->method('createRecord')\n ->willReturnCallback(function ($type, $data) {\n if ($type === 'ContentNote') {\n $decoded = base64_decode($data['Content']);\n $this->assertStringContainsString('<br>', $decoded);\n $this->assertStringNotContainsString('<br />', $decoded);\n\n return 'note-id-789';\n }\n\n return 'link-id-012';\n });\n\n $bodyWithLineBreaks = \"Line 1\\nLine 2\\nLine 3\";\n\n $result = $this->invokePrivateMethod('buildEnhancedNote', $service, [\n 'Test Title',\n $bodyWithLineBreaks,\n 'object-id-456',\n ]);\n\n $this->assertEquals('note-id-789', $result);\n }\n\n public function testBuildEnhancedNoteHandlesComplexScenario(): void\n {\n $service = $this->getServiceMock(['createRecord']);\n\n $profile = new Profile();\n $profile->setAttribute('crm_provider_id', 'owner-complex');\n $service->profile = $profile;\n\n $service->expects($this->exactly(2))\n ->method('createRecord')\n ->willReturnCallback(function ($type, $data) {\n if ($type === 'ContentNote') {\n $decoded = base64_decode($data['Content']);\n\n $this->assertStringContainsString(\"Welch's\", $decoded);\n $this->assertStringContainsString(\"Facebook's Club\", $decoded);\n $this->assertStringContainsString(\"Arctics'\", $decoded);\n $this->assertStringNotContainsString(''', $decoded);\n $this->assertStringNotContainsString('&#039;', $decoded);\n $this->assertStringContainsString('<br>', $decoded);\n $this->assertStringContainsString('<', $decoded);\n $this->assertStringContainsString('>', $decoded);\n\n return 'note-complex';\n }\n\n return 'link-complex';\n });\n\n $complexBody = \"Summary:\\n---------\\nThe call focused on understanding Welch's current challenges and exploring how Arctics' Revenue Growth Management solutions could support their strategic goals.\\n\\n• John SMith discussed his role as a category advisor for Google and Facebook's Club, emphasizing the importance of market research and advising on product assortment.\\n• Madona introduced Arctics' Virtual Shoppers AI, which simulates consumer <behavior> to optimize pricing and promotional strategies.\";\n\n $result = $this->invokePrivateMethod('buildEnhancedNote', $service, [\n 'Jiminny Transcription Summary',\n $complexBody,\n 'task-id-001',\n ]);\n\n $this->assertEquals('note-complex', $result);\n }\n\n public function testSyncRemotelyDeletedObjectsWithErrorHandlingSuccess(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['syncRemotelyDeletedObjects'])\n ->getMock();\n\n // Mock team\n $team = $this->createMock(Team::class);\n $team->method('getUuid')->willReturn('team-uuid-123');\n $serviceMock->team = $team;\n\n // Expect syncRemotelyDeletedObjects to be called once and succeed\n $serviceMock->expects($this->once())\n ->method('syncRemotelyDeletedObjects')\n ->with(\\Jiminny\\Enums\\CrmObject::ACCOUNT);\n\n // Call the protected method using reflection\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');\n $method->setAccessible(true);\n\n // Should not throw any exceptions\n $method->invoke($serviceMock, \\Jiminny\\Enums\\CrmObject::ACCOUNT);\n\n $this->assertTrue(true); // Test completed successfully\n }\n\n public function testSyncRemotelyDeletedObjectsWithErrorHandlingFailure(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['syncRemotelyDeletedObjects'])\n ->getMock();\n\n // Mock team\n $team = $this->createMock(Team::class);\n $team->method('getUuid')->willReturn('team-uuid-456');\n $serviceMock->team = $team;\n\n // Mock logger to verify warning is logged\n $logger = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n\n // Use reflection to set the protected logger property\n $reflection = new \\ReflectionClass($serviceMock);\n $loggerProperty = $reflection->getProperty('logger');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($serviceMock, $logger);\n\n $exception = new \\Exception('Sync failed due to API error');\n\n // Expect syncRemotelyDeletedObjects to throw an exception\n $serviceMock->expects($this->once())\n ->method('syncRemotelyDeletedObjects')\n ->with(\\Jiminny\\Enums\\CrmObject::CONTACT)\n ->willThrowException($exception);\n\n // Expect warning to be logged with correct message and parameters\n $logger->expects($this->once())\n ->method('warning')\n ->with(\n '[Salesforce] Remotely deleted objects sync failed',\n [\n 'objectType' => 'contact',\n 'teamId' => 'team-uuid-456',\n 'reason' => 'Sync failed due to API error',\n ]\n );\n\n // Call the protected method using reflection\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');\n $method->setAccessible(true);\n\n // Should not re-throw the exception, just log it\n $method->invoke($serviceMock, \\Jiminny\\Enums\\CrmObject::CONTACT);\n\n $this->assertTrue(true); // Test completed successfully\n }\n\n public function testSyncRemotelyDeletedObjectsWithErrorHandlingWithLogParams(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['syncRemotelyDeletedObjects'])\n ->getMock();\n\n // Mock team\n $team = $this->createMock(Team::class);\n $team->method('getUuid')->willReturn('team-uuid-789');\n $serviceMock->team = $team;\n\n // Mock logger to verify warning is logged\n $logger = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n\n // Use reflection to set the protected logger property\n $loggerReflection = new \\ReflectionClass($serviceMock);\n $loggerProperty = $loggerReflection->getProperty('logger');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($serviceMock, $logger);\n\n $exception = new \\Exception('Network timeout');\n\n // Expect syncRemotelyDeletedObjects to throw an exception\n $serviceMock->expects($this->once())\n ->method('syncRemotelyDeletedObjects')\n ->with(\\Jiminny\\Enums\\CrmObject::OPPORTUNITY)\n ->willThrowException($exception);\n\n // Additional log parameters\n $logParams = [\n 'syncType' => 'full',\n 'batchSize' => 100,\n ];\n\n // Expect warning to be logged with merged parameters\n $logger->expects($this->once())\n ->method('warning')\n ->with(\n '[Salesforce] Remotely deleted objects sync failed',\n [\n 'objectType' => 'opportunity',\n 'teamId' => 'team-uuid-789',\n 'reason' => 'Network timeout',\n 'syncType' => 'full',\n 'batchSize' => 100,\n ]\n );\n\n // Call the protected method using reflection\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');\n $method->setAccessible(true);\n\n // Should not re-throw the exception, just log it\n $method->invoke($serviceMock, \\Jiminny\\Enums\\CrmObject::OPPORTUNITY, $logParams);\n\n $this->assertTrue(true); // Test completed successfully\n }\n\n /**\n * @dataProvider crmObjectProvider\n */\n public function testSyncRemotelyDeletedObjectsWithErrorHandlingDifferentCrmObjects(\\Jiminny\\Enums\\CrmObject $crmObject): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['syncRemotelyDeletedObjects'])\n ->getMock();\n\n // Mock team\n $team = $this->createMock(Team::class);\n $team->method('getUuid')->willReturn('team-uuid-test');\n $serviceMock->team = $team;\n\n // Mock logger to verify warning is logged\n $logger = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n\n // Use reflection to set the protected logger property\n $loggerReflectionClass = new \\ReflectionClass($serviceMock);\n $loggerProperty = $loggerReflectionClass->getProperty('logger');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($serviceMock, $logger);\n\n $exception = new \\Exception('Test error');\n\n // Expect syncRemotelyDeletedObjects to throw an exception\n $serviceMock->expects($this->once())\n ->method('syncRemotelyDeletedObjects')\n ->with($crmObject)\n ->willThrowException($exception);\n\n // Expect warning to be logged with correct entity type\n $logger->expects($this->once())\n ->method('warning')\n ->with(\n '[Salesforce] Remotely deleted objects sync failed',\n [\n 'objectType' => $crmObject->value,\n 'teamId' => 'team-uuid-test',\n 'reason' => 'Test error',\n ]\n );\n\n // Call the protected method using reflection\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');\n $method->setAccessible(true);\n\n $method->invoke($serviceMock, $crmObject);\n\n $this->assertTrue(true); // Test completed successfully\n }\n\n public function testHandleObjectDeletionWithDeletedEntity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['deleteCrmObject'])\n ->getMock();\n\n $entity = $this->createMock(\\Jiminny\\Models\\Account::class);\n $crmData = ['IsDeleted' => true];\n\n $serviceMock->expects($this->once())\n ->method('deleteCrmObject')\n ->with($entity);\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('handleObjectDeletion');\n $method->setAccessible(true);\n\n $method->invoke($serviceMock, $entity, $crmData);\n }\n\n public function testHandleObjectDeletionWithNonDeletedEntity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['deleteCrmObject'])\n ->getMock();\n\n $entity = $this->createMock(\\Jiminny\\Models\\Contact::class);\n $crmData = ['IsDeleted' => false];\n\n $serviceMock->expects($this->never())\n ->method('deleteCrmObject');\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('handleObjectDeletion');\n $method->setAccessible(true);\n\n $method->invoke($serviceMock, $entity, $crmData);\n }\n\n public function testDeleteCrmObjectWithValidEntity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['dispatchDeleteCrmObjectJob'])\n ->getMock();\n\n $entity = $this->createMock(\\Jiminny\\Models\\Lead::class);\n $entity->expects($this->once())->method('delete');\n\n $serviceMock->expects($this->once())\n ->method('dispatchDeleteCrmObjectJob')\n ->with($entity);\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('deleteCrmObject');\n $method->setAccessible(true);\n\n $method->invoke($serviceMock, $entity);\n }\n\n public function testDeleteCrmObjectWithNullEntity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['dispatchDeleteCrmObjectJob'])\n ->getMock();\n\n $serviceMock->expects($this->never())\n ->method('dispatchDeleteCrmObjectJob');\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('deleteCrmObject');\n $method->setAccessible(true);\n\n $method->invoke($serviceMock, null);\n }\n\n public function testDispatchDeleteCrmObjectJobWithNullEntity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($service);\n $method = $reflection->getMethod('dispatchDeleteCrmObjectJob');\n $method->setAccessible(true);\n\n // Should return early without dispatching - no exception expected\n $method->invoke($service, null);\n\n $this->assertTrue(true); // Test completed successfully\n }\n\n public function testDispatchDeleteCrmObjectJobWithUnsupportedEntity(): void\n {\n $this->expectException(\\TypeError::class);\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n $unsupportedEntity = $this->createMock(\\stdClass::class);\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($service);\n $method = $reflection->getMethod('dispatchDeleteCrmObjectJob');\n\n // This will throw TypeError due to union type constraint\n $method->invoke($service, $unsupportedEntity);\n }\n\n public function testHandleEntityDeletionByProviderIdMethodExists(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n // Test that the method exists and is accessible via reflection\n $reflection = new \\ReflectionClass($service);\n $method = $reflection->getMethod('handleEntityDeletionByProviderId');\n $method->setAccessible(true);\n\n // Verify method exists and has correct parameters\n $this->assertTrue($method->isProtected());\n $this->assertEquals(2, $method->getNumberOfParameters());\n\n $parameters = $method->getParameters();\n $this->assertEquals('targetEntity', $parameters[0]->getName());\n $this->assertEquals('crmData', $parameters[1]->getName());\n }\n\n public function testSyncRemotelyDeletedObjectsWithNoResults(): void\n {\n // Create a real service instance to avoid mock property issues\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n // Mock queryHandler to throw NoResultsException\n $queryHandler = $this->createMock(QueryHandler::class);\n $queryHandler->expects($this->once())\n ->method('queryDeleted')\n ->with('Opportunity')\n ->willThrowException(new NoResultsException('No results'));\n\n // Set the queryHandler using reflection on the real service\n $reflection = new \\ReflectionClass($service);\n $queryHandlerProperty = $reflection->getProperty('queryHandler');\n $queryHandlerProperty->setAccessible(true);\n $queryHandlerProperty->setValue($service, $queryHandler);\n\n $result = self::invokePrivateMethod('syncRemotelyDeletedObjects', $service, [CrmObject::OPPORTUNITY]);\n\n $this->assertFalse($result);\n }\n\n public function testSyncRemotelyDeletedObjectsWithEmptyResults(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n // Mock queryHandler to return empty results\n $queryResult = $this->createMock(QueryResults::class);\n $queryResult->method('getResults')->willReturn([]);\n\n $queryHandler = $this->createMock(QueryHandler::class);\n $queryHandler->expects($this->once())\n ->method('queryDeleted')\n ->with('Opportunity')\n ->willReturn($queryResult);\n\n // Set the queryHandler using reflection on the real service\n $reflection = new \\ReflectionClass($service);\n $queryHandlerProperty = $reflection->getProperty('queryHandler');\n $queryHandlerProperty->setAccessible(true);\n $queryHandlerProperty->setValue($service, $queryHandler);\n\n $result = self::invokePrivateMethod('syncRemotelyDeletedObjects', $service, [CrmObject::OPPORTUNITY]);\n\n $this->assertFalse($result);\n }\n\n public function testSyncRemotelyDeletedObjectsWithUnsupportedCrmObject(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n // Mock queryHandler to return some deleted objects so we reach the match statement\n $deletedObjects = [\n ['id' => 'task1'],\n ['id' => 'task2'],\n ];\n $queryResult = $this->createMock(QueryResults::class);\n $queryResult->method('getResults')->willReturn($deletedObjects);\n\n $queryHandler = $this->createMock(QueryHandler::class);\n $queryHandler->expects($this->once())\n ->method('queryDeleted')\n ->with('Task') // ucfirst('task') = 'Task'\n ->willReturn($queryResult);\n\n self::setPrivateProperty($service, 'queryHandler', $queryHandler);\n\n $this->expectException(InvalidArgumentException::class);\n $this->expectExceptionMessage('Unsupported CrmObject: task');\n\n self::invokePrivateMethod('syncRemotelyDeletedObjects', $service, [CrmObject::TASK]);\n }\n\n public static function crmObjectProvider(): array\n {\n return [\n 'Account' => [CrmObject::ACCOUNT],\n 'Contact' => [CrmObject::CONTACT],\n 'Lead' => [CrmObject::LEAD],\n 'Opportunity' => [CrmObject::OPPORTUNITY],\n ];\n }\n\n public function testVerifyTaskExistsReturnsTrue(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-123', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-123');\n $activity->method('getId')->willReturn(456);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-123', ['Id', 'IsDeleted'])\n ->willReturn(['Id' => 'task-123', 'IsDeleted' => false]);\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertTrue($result);\n }\n\n public function testVerifyTaskExistsReturnsTrueForEvent(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:event-123', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('event-123');\n $activity->method('getId')->willReturn(456);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_EVENT);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Event', 'event-123', ['Id', 'IsDeleted'])\n ->willReturn(['Id' => 'event-123', 'IsDeleted' => false]);\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertTrue($result);\n }\n\n public function testVerifyTaskExistsReturnsFalseWhenDeleted(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-456', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-456');\n $activity->method('getId')->willReturn(789);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-456', ['Id', 'IsDeleted'])\n ->willReturn(['Id' => 'task-456', 'IsDeleted' => true]);\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertFalse($result);\n }\n\n public function testVerifyTaskExistsReturnsFalseWhenNotFound(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-999', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-999');\n $activity->method('getId')->willReturn(999);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-999', ['Id', 'IsDeleted'])\n ->willThrowException(new \\Jiminny\\Exceptions\\HttpNotFoundException('Task not found'));\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertFalse($result);\n }\n\n public function testVerifyTaskExistsReturnsFalseWhenNoPlaybook(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-no-playbook', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-no-playbook');\n $activity->method('getId')->willReturn(111);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn(null);\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertFalse($result);\n }\n\n public function testVerifyTaskExistsThrowsExceptionForTransientErrors(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-error', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-error');\n $activity->method('getId')->willReturn(888);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-error', ['Id', 'IsDeleted'])\n ->willThrowException(new \\RuntimeException('Network timeout'));\n\n $this->expectException(\\RuntimeException::class);\n $this->expectExceptionMessage('Network timeout');\n\n $service->verifyTaskExists($activity);\n }\n\n public function testVerifyTaskExistsCachesResults(): void\n {\n $cachedValue = null;\n Cache::shouldReceive('remember')\n ->twice()\n ->with('crm_task_exists:123:task-cached', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(function ($key, $ttl, $callback) use (&$cachedValue) {\n if ($cachedValue === null) {\n $cachedValue = $callback();\n }\n\n return $cachedValue;\n });\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-cached');\n $activity->method('getId')->willReturn(555);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-cached', ['Id', 'IsDeleted'])\n ->willReturn(['Id' => 'task-cached', 'IsDeleted' => false]);\n\n $result1 = $service->verifyTaskExists($activity);\n $result2 = $service->verifyTaskExists($activity);\n\n $this->assertTrue($result1);\n $this->assertTrue($result2);\n }\n\n public function testVerifyTaskExistsReturnsFalseForHttpBadRequestException(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-400', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-400');\n $activity->method('getId')->willReturn(400);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-400', ['Id', 'IsDeleted'])\n ->willThrowException(new \\Jiminny\\Exceptions\\HttpBadRequestException('Bad request'));\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertFalse($result);\n }\n\n public function testImportOpportunitySkipsWhenNoProfileAndNoAccount(): void\n {\n $crmData = [\n 'Id' => 'SF-NO-USER-1',\n 'Name' => 'Test Opportunity',\n 'OwnerId' => 'owner-no-profile',\n // No AccountId\n ];\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(\\Illuminate\\Events\\Dispatcher::class); // ← ADD THIS\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n\n $service = new Service(\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService\n );\n\n $config = $this->createMock(Configuration::class);\n\n // Mock profiles relation returning null (no profile found)\n $profilesRelation = \\Mockery::mock(\\Illuminate\\Database\\Eloquent\\Relations\\HasMany::class);\n $profilesRelation->shouldReceive('where')->with('crm_provider_id', 'owner-no-profile')->andReturnSelf();\n $profilesRelation->shouldReceive('first')->andReturn(null);\n\n $config->expects($this->once())\n ->method('profiles')\n ->willReturn($profilesRelation);\n\n $team = $this->createMock(Team::class);\n $team->method('getId')->willReturn(1);\n\n $logger = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n $logger->expects($this->once())\n ->method('error')\n ->with(\n '[Salesforce] | Skip import, no user_id found',\n ['id' => 'SF-NO-USER-1']\n );\n\n $reflection = new \\ReflectionClass($service);\n\n $configProperty = $reflection->getProperty('config');\n $configProperty->setAccessible(true);\n $configProperty->setValue($service, $config);\n\n $teamProperty = $reflection->getProperty('team');\n $teamProperty->setAccessible(true);\n $teamProperty->setValue($service, $team);\n\n $loggerProperty = $reflection->getProperty('logger');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($service, $logger);\n\n // Initialize profile property to avoid \"must not be accessed before initialization\" error\n $profileProperty = $reflection->getProperty('profile');\n $profileProperty->setAccessible(true);\n $profileProperty->setValue($service, null);\n\n $result = self::invokePrivateMethod('importOpportunity', $service, [$crmData]);\n\n $this->assertNull($result);\n }\n\n public function testImportOpportunitySkipsWhenProfileNotFound(): void\n {\n $crmData = [\n 'Id' => 'SF-NO-USER-2',\n 'Name' => 'Test Opportunity',\n 'OwnerId' => 'owner-not-found',\n // No AccountId - avoid complex account processing\n ];\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $eventDispatcher = $this->createMock(\\Illuminate\\Events\\Dispatcher::class); // ← ADD THIS\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n\n\n $service = new Service(\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService\n );\n\n $config = $this->createMock(Configuration::class);\n\n // Mock profiles relation returning null (no profile found)\n $profilesRelation = \\Mockery::mock(\\Illuminate\\Database\\Eloquent\\Relations\\HasMany::class);\n $profilesRelation->shouldReceive('where')->with('crm_provider_id', 'owner-not-found')->andReturnSelf();\n $profilesRelation->shouldReceive('first')->andReturn(null);\n\n $config->expects($this->once())\n ->method('profiles')\n ->willReturn($profilesRelation);\n\n $team = $this->createMock(Team::class);\n $team->method('getId')->willReturn(1);\n\n $logger = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n $logger->expects($this->once())\n ->method('error')\n ->with(\n '[Salesforce] | Skip import, no user_id found',\n ['id' => 'SF-NO-USER-2']\n );\n\n $reflection = new \\ReflectionClass($service);\n\n $configProperty = $reflection->getProperty('config');\n $configProperty->setAccessible(true);\n $configProperty->setValue($service, $config);\n\n $teamProperty = $reflection->getProperty('team');\n $teamProperty->setAccessible(true);\n $teamProperty->setValue($service, $team);\n\n $loggerProperty = $reflection->getProperty('logger');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($service, $logger);\n\n // Initialize profile property\n $profileProperty = $reflection->getProperty('profile');\n $profileProperty->setAccessible(true);\n $profileProperty->setValue($service, null);\n\n $result = self::invokePrivateMethod('importOpportunity', $service, [$crmData]);\n\n $this->assertNull($result);\n }\n}\n\nclass HasManyExtended extends HasMany\n{\n public function where()\n {\n }\n\n public function first()\n {\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Services\\Crm\\Salesforce;\n\nuse Illuminate\\Database\\Eloquent\\Relations\\HasMany;\nuse Illuminate\\Events\\Dispatcher;\nuse Illuminate\\Support\\Facades\\Cache;\nuse Jiminny\\Component\\Country\\CountriesMap;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Enums\\CrmObject;\nuse Jiminny\\Exceptions\\InvalidArgumentException;\nuse Jiminny\\Exceptions\\NoResultsException;\nuse Jiminny\\Integrations\\PlaybookResolver;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Crm\\Configuration;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\TeamSettings;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\ContactRoleRepository;\nuse Jiminny\\Repositories\\Crm\\FieldDataRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\Salesforce\\Client;\nuse Jiminny\\Services\\Crm\\Salesforce\\PayloadBuilder;\nuse Jiminny\\Services\\Crm\\Salesforce\\QueryBuilder;\nuse Jiminny\\Services\\Crm\\Salesforce\\QueryHandler;\nuse Jiminny\\Services\\Crm\\Salesforce\\QueryIterator;\nuse Jiminny\\Services\\Crm\\Salesforce\\QueryResults;\nuse Jiminny\\Services\\Crm\\Salesforce\\Service;\nuse PHPUnit\\Framework\\MockObject\\MockObject;\nuse Tests\\TestCase;\nuse Tests\\Unit\\Traits\\TestPrivateMethod;\n\nclass ServiceTest extends TestCase\n{\n use TestPrivateMethod;\n\n public function testFetchAndAssociateRelatedActivity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $payloadBuilder->method('addCustomLogicFieldsPayload')\n ->willReturnCallback(function ($activity, $payload) {\n return $payload;\n });\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods(['fetchRelatedActivity', 'getPlaybook', 'getPlaybookCategory', 'updateRecord'])\n ->getMock();\n\n $serviceMock->expects($this->once())\n ->method('fetchRelatedActivity')\n ->willReturn([\n 'Id' => 'testId',\n 'Type' => null,\n 'OwnerId' => 'testerUser',\n 'Description' => 'Test description',\n ]);\n\n $user = $this->createMock(User::class);\n $team = $this->createMock(Team::class);\n $user->method('getAttribute')->with('team')->willReturn($team);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityField')->willReturn(null);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_EVENT);\n\n $serviceMock->expects($this->once())\n ->method('getPlaybook')\n ->with($user)\n ->willReturn($playbook);\n\n $serviceMock->expects($this->never())\n ->method('getPlaybookCategory');\n\n $serviceMock->expects($this->never())\n ->method('updateRecord');\n\n $fieldDataRepository = $this->createMock(FieldDataRepository::class);\n $fieldDataRepository->method('getActivityFieldData')->willReturn(collect([]));\n app()->instance(FieldDataRepository::class, $fieldDataRepository);\n\n $config = $this->createMock(Configuration::class);\n $profilesRelation = $this->getMockBuilder(\\Illuminate\\Database\\Eloquent\\Relations\\HasMany::class)\n ->disableOriginalConstructor()\n ->onlyMethods(['get'])\n ->addMethods(['where'])\n ->getMock();\n $profilesRelation->method('where')->willReturnSelf();\n $profilesRelation->method('get')->willReturn(collect([]));\n $config->method('profiles')->willReturn($profilesRelation);\n\n $serviceMock->config = $config;\n $serviceMock->profile = null;\n\n $actualStartTime = \\Carbon\\Carbon::now();\n\n $activity = $this->getMockBuilder(Activity::class)\n ->disableOriginalConstructor()\n ->onlyMethods(['update', 'hasProspect'])\n ->getMock();\n\n $activity->method('update')->willReturn(true);\n $activity->method('hasProspect')->willReturn(true);\n\n $activity->type = Activity::TYPE_CONFERENCE;\n $activity->provider = Activity::PROVIDER_TWILIO;\n $activity->lead_id = 1;\n $activity->user_id = 0;\n $activity->id_string = 'test-activity-id';\n $activity->user = $user;\n\n $activity->actual_start_time = $actualStartTime;\n $activity->uuid = 'c53d8320-f556-4cee-a2f8-5f232f454ca4';\n\n app()->bind(PlaybookResolver::class, function () use ($user) {\n $playbook = $this->createMock(Playbook::class);\n $playbookResolver = $this->createMock(PlaybookResolver::class);\n $playbookResolver->expects($this->once())\n ->method('resolvePlaybookByUser')\n ->with($user)\n ->willReturn($playbook);\n\n return $playbookResolver;\n });\n\n $data = $serviceMock->fetchAndAssociateRelatedActivity($activity);\n\n $this->assertInstanceOf(Activity::class, $data);\n $this->assertEquals(Activity::TYPE_CONFERENCE, $data->getType());\n $this->assertEquals($actualStartTime->getTimestamp(), $data->getActualStartTime()->getTimestamp());\n }\n\n public function testFetchAndAssociateRelatedActivitySkipsForTaskBasedPlaybook(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods(['fetchRelatedActivity', 'getPlaybook'])\n ->getMock();\n\n $user = $this->createMock(User::class);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n $playbook->method('getId')->willReturn(123);\n\n $serviceMock->expects($this->once())\n ->method('getPlaybook')\n ->with($user)\n ->willReturn($playbook);\n\n $serviceMock->expects($this->never())\n ->method('fetchRelatedActivity');\n\n $activity = $this->getMockBuilder(Activity::class)\n ->disableOriginalConstructor()\n ->onlyMethods(['hasProspect', 'getUuid'])\n ->getMock();\n\n $activity->method('hasProspect')->willReturn(true);\n $activity->method('getUuid')->willReturn('c53d8320-f556-4cee-a2f8-5f232f454ca4');\n $activity->type = Activity::TYPE_CONFERENCE;\n $activity->actual_start_time = \\Carbon\\Carbon::now();\n $activity->user = $user;\n\n $result = $serviceMock->fetchAndAssociateRelatedActivity($activity);\n\n $this->assertNull($result);\n }\n\n public function testFetchAndAssociateRelatedActivityReturnsNullForNonConference(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $serviceMock = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)\n );\n\n $activity = $this->getMockBuilder(Activity::class)\n ->disableOriginalConstructor()\n ->getMock();\n\n $activity->type = Activity::TYPE_SOFTPHONE;\n\n $result = $serviceMock->fetchAndAssociateRelatedActivity($activity);\n\n $this->assertNull($result);\n }\n\n public function testFetchAndAssociateRelatedActivityReturnsNullWhenNoStartTime(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $serviceMock = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)\n );\n\n\n $activity = $this->getMockBuilder(Activity::class)\n ->disableOriginalConstructor()\n ->getMock();\n\n $activity->type = Activity::TYPE_CONFERENCE;\n $activity->actual_start_time = null;\n $activity->scheduled_start_time = null;\n\n $result = $serviceMock->fetchAndAssociateRelatedActivity($activity);\n\n $this->assertNull($result);\n }\n\n public function testFetchAndAssociateRelatedActivityReturnsNullWhenNoProspect(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods(['getPlaybook'])\n ->getMock();\n\n $serviceMock->expects($this->never())\n ->method('getPlaybook');\n\n $activity = $this->getMockBuilder(Activity::class)\n ->disableOriginalConstructor()\n ->onlyMethods(['hasProspect', 'getUuid'])\n ->getMock();\n\n $activity->method('hasProspect')->willReturn(false);\n $activity->method('getUuid')->willReturn('c53d8320-f556-4cee-a2f8-5f232f454ca4');\n $activity->type = Activity::TYPE_CONFERENCE;\n $activity->actual_start_time = \\Carbon\\Carbon::now();\n\n $result = $serviceMock->fetchAndAssociateRelatedActivity($activity);\n\n $this->assertNull($result);\n }\n\n public function testMatchExactlyByEmail(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods([])\n ->getMock();\n\n $profile = new Profile();\n $profile->setAttribute('id', bin2hex(random_bytes(8)));\n $serviceMock->profile = $profile;\n\n $team = $this->createMock(Team::class);\n $serviceMock->team = $team;\n\n $data = $serviceMock->matchExactlyByEmail(bin2hex(random_bytes(8)) . 'test_email@testserver.com');\n\n $this->assertEquals(null, $data);\n }\n\n public function testMatchDomainFromEmail(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $queryIterator = $this->createMock(QueryIterator::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $config = $this->createMock(Configuration::class);\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('search')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods(['convertCrmData'])\n ->getMock();\n\n $profile = new Profile();\n $profile->account_fields = 'Field1, Field2, Field3';\n $serviceMock->profile = $profile;\n\n $serviceMock->expects($this->once())\n ->method('convertCrmData')\n ->willReturn(['test']);\n\n $this->app->bind(QueryBuilder::class, function () {\n $queryBuilder = $this->createMock(QueryBuilder::class);\n $queryBuilder->expects($this->once())\n ->method('buildMatchByDomainQuery')\n ->with('test_email@testserver.com')\n ->willReturn('FIND {test_email@testserver.com} IN ALL FIELDS RETURNING Account(Id)');\n\n return $queryBuilder;\n });\n\n $team = $this->createMock(Team::class);\n\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n\n $data = $serviceMock->matchByDomain('test_email@testserver.com');\n\n $this->assertEquals(['test'], $data);\n }\n\n public function testBuildTaskSearchFields(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)\n );\n\n $fields = $service->buildTaskSearchFields();\n\n $expectedFields = ['Id', 'WhoId', 'WhatId', 'AccountId'];\n\n $this->assertEquals($expectedFields, $fields);\n }\n\n public function testMapCrmObjects(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class),\n );\n\n $sampleTask = [\n 'WhoId' => '003sampleWhoId',\n 'AccountId' => 'sampleAccountId',\n 'WhatId' => 'sampleWhatId',\n ];\n\n $activityData = $service->mapCrmObjects($sampleTask);\n\n $expectedActivityData = [\n 'contact' => '003sampleWhoId',\n 'account' => 'sampleAccountId',\n 'opportunity' => 'sampleWhatId',\n ];\n\n $this->assertEquals($expectedActivityData, $activityData);\n }\n\n public function testGetInstalledAppVersion(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n $queryIterator = $this->createMock(QueryIterator::class);\n $queryIterator->expects($this->any())\n ->method('current')->willReturn([\n 'SubscriberPackageVersion' => [\n 'MajorVersion' => '1',\n 'MinorVersion' => '0',\n 'PatchVersion' => '1',\n 'BuildNumber' => '0',\n ],\n ]);\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('metadata')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods(array_diff(get_class_methods(Service::class), ['getInstalledAppVersion']))\n ->getMock();\n\n $version = $serviceMock->getInstalledAppVersion();\n\n $this->assertEquals('1010', $version);\n }\n\n public function testSyncProfiles(): void\n {\n $userToSearch = null;\n\n $team = $this->createMock(Team::class);\n $config = $this->createMockedConfiguration();\n $config->expects($this->once())\n ->method('getId')\n ->willReturn(1);\n\n $salesforceUser = [\n 'Email' => 'test@example.com',\n 'UserPreferencesLightningExperiencePreferred' => true,\n 'CallCenterId' => '123',\n 'Id' => '456',\n 'ProfileId' => '789',\n ];\n\n app()->bind(QueryBuilder::class, function () use ($userToSearch) {\n $queryBuilder = $this->createMock(QueryBuilder::class);\n $queryBuilder->expects($this->once())\n ->method('buildGetUsersQuery')\n ->with($userToSearch)\n ->willReturn('SELECT * FROM Users');\n\n return $queryBuilder;\n });\n\n $queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('query')\n ->with('SELECT * FROM Users')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n\n $user = $this->createMock(User::class);\n $user->expects($this->once())\n ->method('getId')\n ->willReturn(123);\n\n $this->mockTeamRepository($team, $salesforceUser, $user);\n\n $profileRepository = $this->createMock(ProfileRepository::class);\n $profileRepository->expects($this->once())\n ->method('updateOrCreateProfile')\n ->with(\n $user,\n [\n 'crm_configuration_id' => 1,\n 'crm_provider_id' => '456',\n ],\n [\n 'user_id' => 123,\n 'edition' => Profile::EDITION_LIGHTNING,\n 'has_external_cti' => true,\n 'crm_profile_id' => '789',\n ]\n )\n ->willReturn(new Profile());\n\n $this->app->instance(ProfileRepository::class, $profileRepository);\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n $result = $serviceMock->syncProfiles($userToSearch);\n\n $this->assertNull($result);\n }\n\n public function testSyncProfilesEmailIsNull(): void\n {\n $userToSearch = $this->createMock(User::class);\n\n $salesforceUser = [\n 'Email' => null,\n ];\n\n app()->bind(QueryBuilder::class, function () {\n $queryBuilder = $this->createMock(QueryBuilder::class);\n $queryBuilder->expects($this->once())\n ->method('buildGetUsersQuery')\n ->with(null)\n ->willReturn('SELECT * FROM Users');\n\n return $queryBuilder;\n });\n\n $queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->exactly(2))\n ->method('query')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n\n $team = $this->createMock(Team::class);\n $user = $this->createMock(User::class);\n $this->mockTeamRepository($team, $salesforceUser, $user, false);\n\n $config = $this->createMock(Configuration::class);\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n\n $profile = $serviceMock->syncProfiles(null);\n\n $this->assertNull($profile);\n }\n\n public function testSyncProfilesUserToSearchMatchesCurrentUser(): void\n {\n $userToSearch = $this->createMock(User::class);\n $userToSearch->expects($this->once())\n ->method('getId')\n ->willReturn(123);\n\n $team = $this->createMock(Team::class);\n $config = $this->createMock(Configuration::class);\n $config->expects($this->once())\n ->method('getId')\n ->willReturn(1);\n\n $salesforceUser = [\n 'Email' => 'test@example.com',\n 'UserPreferencesLightningExperiencePreferred' => true,\n 'CallCenterId' => '123',\n 'Id' => '456',\n 'ProfileId' => '789',\n ];\n\n app()->bind(QueryBuilder::class, function () use ($userToSearch) {\n $queryBuilder = $this->createMock(QueryBuilder::class);\n $queryBuilder->expects($this->once())\n ->method('buildGetUsersQuery')\n ->with($userToSearch)\n ->willReturn('SELECT * FROM Users');\n\n return $queryBuilder;\n });\n\n $queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('query')\n ->with('SELECT * FROM Users')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n\n $user = $this->createMock(User::class);\n $user->expects($this->exactly(2))\n ->method('getId')\n ->willReturn(123);\n\n $this->mockTeamRepository($team, $salesforceUser, $user);\n\n $profileRepository = $this->createMock(ProfileRepository::class);\n $profileRepository->expects($this->once())\n ->method('updateOrCreateProfile')\n ->with(\n $user,\n [\n 'crm_configuration_id' => 1,\n 'crm_provider_id' => '456',\n ],\n [\n 'user_id' => 123,\n 'edition' => Profile::EDITION_LIGHTNING,\n 'has_external_cti' => true,\n 'crm_profile_id' => '789',\n ]\n )\n ->willReturn(new Profile());\n\n $this->app->instance(ProfileRepository::class, $profileRepository);\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n $profile = $serviceMock->syncProfiles($userToSearch);\n\n $this->assertInstanceOf(Profile::class, $profile);\n }\n\n public function testSyncProfilesWithCustomValidation(): void\n {\n $userToSearch = null;\n\n $team = $this->createMock(Team::class);\n $config = $this->createMockedConfiguration();\n $config->expects($this->atLeastOnce()) // Changed from once() to atLeastOnce()\n ->method('getId')\n ->willReturn(1);\n\n $salesforceUser = [\n 'Email' => 'test@example.com',\n 'UserPreferencesLightningExperiencePreferred' => true,\n 'CallCenterId' => '123',\n 'Id' => '456',\n 'ProfileId' => '789',\n 'CustomField' => 'CustomValue',\n ];\n\n $customRules = [\n ['field' => 'CustomField', 'value' => 'CustomValue'],\n ];\n\n $this->mockQueryBuilderAndHandler($userToSearch, [$salesforceUser]);\n\n $user = $this->createMock(User::class);\n $user->expects($this->once())\n ->method('getId')\n ->willReturn(123);\n\n $this->mockTeamRepository($team, $salesforceUser, $user, true, $customRules);\n\n $this->mockProfileRepository($user);\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n $profile = $serviceMock->syncProfiles($userToSearch);\n\n $this->assertNull($profile);\n }\n\n public function testSyncProfilesWithCustomValidationFailing(): void\n {\n $userToSearch = null;\n\n $team = $this->createMock(Team::class);\n $config = $this->createMockedConfiguration();\n\n $salesforceUser = [\n 'Email' => 'test@example.com',\n 'UserPreferencesLightningExperiencePreferred' => true,\n 'CallCenterId' => '123',\n 'Id' => '456',\n 'ProfileId' => '789',\n 'CustomField' => 'WrongValue',\n ];\n\n $customRules = [\n ['field' => 'CustomField', 'value' => 'CustomValue'],\n ];\n\n $this->mockQueryBuilderAndHandler($userToSearch, [$salesforceUser]);\n\n $teamRepository = $this->getMockForAbstractClass(TeamRepository::class, [], '', false, true, true, ['findActiveTeamMemberByEmail', 'getTeamSetting']);\n\n $teamSettings = $this->createMock(TeamSettings::class);\n $teamSettings->method('getValueType')\n ->willReturn('array');\n\n $teamSettings->method('getValue')\n ->willReturn(json_encode($customRules));\n\n $teamRepository->expects($this->once())\n ->method('getTeamSetting')\n ->with($team, 'custom_profile_validation')\n ->willReturn($teamSettings);\n\n app()->bind(TeamRepository::class, function () use ($teamRepository) {\n return $teamRepository;\n });\n\n $profileRepository = $this->createMock(ProfileRepository::class);\n $profileRepository->expects($this->never())\n ->method('updateOrCreateProfile');\n\n $this->app->instance(ProfileRepository::class, $profileRepository);\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n $result = $serviceMock->syncProfiles($userToSearch);\n\n $this->assertNull($result);\n }\n\n public function testGetContactRolesFromCrm(): void\n {\n $contactRoles = [\n [\n 'Id' => '1',\n 'ContactId' => 'Contact1',\n 'OpportunityId' => 'Opportunity1',\n 'Opportunity' => ['OwnerId' => 'Owner1'],\n 'IsPrimary' => true,\n 'Role' => 'Decision Maker',\n ],\n ];\n\n $expectedResponse = [\n [\n 'id' => '1',\n 'contactId' => 'Contact1',\n 'opportunityId' => 'Opportunity1',\n 'ownerId' => 'Owner1',\n 'isPrimary' => true,\n 'role' => 'Decision Maker',\n ],\n ];\n\n $this->bindQueryIterator($contactRoles);\n\n $serviceMock = $this->getServiceMock();\n\n $data = $serviceMock->getContactRolesFromCrm(now()->subDay());\n\n $this->assertEquals($expectedResponse, $data);\n }\n\n public function testGetContactRolesFromCrmNoResult(): void\n {\n $this->bindQueryIterator([]);\n\n $serviceMock = $this->getServiceMock();\n\n $data = $serviceMock->getContactRolesFromCrm(now()->subDay());\n\n $this->assertEquals([], $data);\n }\n\n public function testSyncContactRoles(): void\n {\n $contactRoles = [\n [\n 'id' => '1',\n 'contactId' => 'Contact1',\n 'opportunityId' => 'Opportunity1',\n 'ownerId' => 'Owner1',\n 'isPrimary' => true,\n 'role' => 'Decision Maker',\n ],\n ];\n\n app()->bind(ContactRoleRepository::class, function () {\n $contactRoleRepository = $this->createMock(ContactRoleRepository::class);\n $contactRoleRepository->expects($this->once())\n ->method('saveContactRoles');\n\n return $contactRoleRepository;\n });\n\n $serviceMock = $this->getServiceMock([\n 'getContactRolesFromCrm',\n 'syncRemotelyDeletedContactRoles',\n 'syncContact',\n 'syncOpportunity',\n ]);\n\n $config = $this->createMock(Configuration::class);\n $hasMany = $this->createMock(HasManyExtended::class);\n $hasMany->expects($this->exactly(2))\n ->method('where')\n ->willReturn($hasMany);\n\n $hasMany->expects($this->exactly(2))\n ->method('first')\n ->willReturn(\n $this->createMock(Contact::class),\n $this->createMock(Opportunity::class)\n );\n\n $config->expects($this->once())\n ->method('contacts')\n ->willReturn($hasMany);\n $config->expects($this->once())\n ->method('opportunities')\n ->willReturn($hasMany);\n\n $serviceMock->config = $config;\n\n $serviceMock->expects($this->once())\n ->method('getContactRolesFromCrm')\n ->willReturn($contactRoles);\n\n $serviceMock->expects($this->once())\n ->method('syncRemotelyDeletedContactRoles');\n\n $data = $serviceMock->syncContactRoles(now()->subDay());\n\n $this->assertEquals(1, $data);\n }\n\n public function testSyncRemotelyDeletedContactRoles(): void\n {\n $contactRoles = [\n [\n 'id' => '1',\n 'crm_provider_id' => '1',\n ],\n ];\n\n app()->bind(QueryHandler::class, function () use ($contactRoles) {\n $queryResults = new QueryResults($contactRoles, 1, true, null);\n\n $handler = $this->createMock(QueryHandler::class);\n $handler->method('queryDeleted')\n ->willReturn($queryResults);\n\n return $handler;\n });\n\n app()->bind(ContactRoleRepository::class, function () {\n $contactRoleRepository = $this->createMock(ContactRoleRepository::class);\n $contactRoleRepository->expects($this->once())\n ->method('deleteContactRoles');\n\n return $contactRoleRepository;\n });\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $this->createMock(Team::class);\n\n $data = $this->invokePrivateMethod('syncRemotelyDeletedContactRoles', $serviceMock, []);\n\n $this->assertTrue($data);\n }\n\n private function bindQueryIterator(array $queryResult): void\n {\n /** @var Client $client */\n $client = $this->createMock(Client::class);\n $queryIterator = new QueryIterator(\n $client,\n new QueryResults($queryResult, 1, true, null)\n );\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('query')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n }\n\n public static function getOpportunitySortOrderDataProvider(): array\n {\n return [\n 'all open recently updated' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED, ['LastModifiedDate DESC', true]],\n 'all open recently created' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED, ['CreatedDate DESC', true]],\n 'all open oldest created' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED, ['CreatedDate ASC', true]],\n 'all recently updated' => [Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED, ['LastModifiedDate DESC', false]],\n 'default' => ['unknown', ['LastModifiedDate DESC', true]],\n ];\n }\n\n private function createMockedConfiguration(): Configuration\n {\n $config = $this->createMock(Configuration::class);\n $profilesRelation = $this->getMockBuilder(\\Illuminate\\Database\\Eloquent\\Relations\\HasMany::class)\n ->disableOriginalConstructor()\n ->onlyMethods(['get'])\n ->addMethods(['where', 'first'])\n ->getMock();\n $profilesRelation->method('where')->willReturnSelf();\n $profilesRelation->method('get')->willReturn(collect([]));\n $profilesRelation->method('first')->willReturn(null);\n $config->method('profiles')->willReturn($profilesRelation);\n\n return $config;\n }\n\n private function getServiceMock(array $onlyMethods = []): MockObject&Service\n {\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $this->createMock(Client::class),\n $this->createMock(PayloadBuilder::class),\n $this->createMock(Dispatcher::class),\n $this->createMock(CountriesMap::class),\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods($onlyMethods)\n ->getMock();\n\n $serviceMock->profile = $this->createMock(Profile::class);\n\n return $serviceMock;\n }\n\n private function mockQueryBuilderAndHandler($userToSearch, $salesforceUsers): void\n {\n app()->bind(QueryBuilder::class, function () use ($userToSearch) {\n $queryBuilder = $this->createMock(QueryBuilder::class);\n $queryBuilder->expects($this->once())\n ->method('buildGetUsersQuery')\n ->with($userToSearch)\n ->willReturn('SELECT * FROM Users');\n\n return $queryBuilder;\n });\n\n $queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults($salesforceUsers, count($salesforceUsers), true, null));\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('query')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n }\n\n private function mockTeamRepository(\n Team $team,\n array $salesforceUser,\n ?User $user = null,\n bool $userSearch = true,\n array $customRules = []\n ): void {\n $teamRepository = $this->getMockForAbstractClass(TeamRepository::class, [], '', false, true, true, ['findActiveTeamMemberByEmail', 'getTeamSetting']);\n\n if ($userSearch) {\n $teamRepository->expects($this->once())\n ->method('findActiveTeamMemberByEmail')\n ->with($team, $salesforceUser['Email'])\n ->willReturn($user);\n }\n\n $teamSettings = $this->createMock(TeamSettings::class);\n $teamSettings->method('getValueType')\n ->willReturn('array');\n\n $teamSettings->method('getValue')\n ->willReturn(json_encode($customRules));\n\n $teamRepository->expects($this->once())\n ->method('getTeamSetting')\n ->with($team, 'custom_profile_validation')\n ->willReturn($teamSettings);\n\n app()->bind(TeamRepository::class, function () use ($teamRepository) {\n return $teamRepository;\n });\n }\n\n private function mockProfileRepository(User $user): void\n {\n $profileRepository = $this->createMock(ProfileRepository::class);\n $profileRepository->expects($this->once())\n ->method('updateOrCreateProfile')\n ->with(\n $user,\n [\n 'crm_configuration_id' => 1,\n 'crm_provider_id' => '456',\n ],\n [\n 'user_id' => 123,\n 'edition' => Profile::EDITION_LIGHTNING,\n 'has_external_cti' => true,\n 'crm_profile_id' => '789',\n ]\n )\n ->willReturn(new Profile());\n\n $this->app->instance(ProfileRepository::class, $profileRepository);\n }\n\n public function testBuildEnhancedNoteDecodesHtmlEntities(): void\n {\n $service = $this->getServiceMock(['createRecord']);\n\n $profile = new Profile();\n $profile->setAttribute('crm_provider_id', 'owner-123');\n $service->profile = $profile;\n\n $service->expects($this->exactly(2))\n ->method('createRecord')\n ->willReturnOnConsecutiveCalls('note-id-123', 'link-id-456');\n\n $bodyWithEntities = 'Welch's current challenges and Facebook's Club';\n\n $result = $this->invokePrivateMethod('buildEnhancedNote', $service, [\n 'Test Title',\n $bodyWithEntities,\n 'object-id-789',\n ]);\n\n $this->assertEquals('note-id-123', $result);\n }\n\n public function testBuildEnhancedNoteSanitizesWithoutQuotes(): void\n {\n $service = $this->getServiceMock(['createRecord']);\n\n $profile = new Profile();\n $profile->setAttribute('crm_provider_id', 'owner-456');\n $service->profile = $profile;\n\n $service->expects($this->exactly(2))\n ->method('createRecord')\n ->willReturnCallback(function ($type, $data) {\n if ($type === 'ContentNote') {\n $decoded = base64_decode($data['Content']);\n $this->assertStringContainsString(\"Welch's\", $decoded);\n $this->assertStringNotContainsString(''', $decoded);\n $this->assertStringNotContainsString('&#039;', $decoded);\n $this->assertStringContainsString('<script>', $decoded);\n\n return 'note-id-456';\n }\n\n return 'link-id-789';\n });\n\n $bodyWithMixedContent = \"Welch's and <script>alert('xss')</script>\";\n\n $result = $this->invokePrivateMethod('buildEnhancedNote', $service, [\n 'Test Title',\n $bodyWithMixedContent,\n 'object-id-123',\n ]);\n\n $this->assertEquals('note-id-456', $result);\n }\n\n public function testBuildEnhancedNoteConvertsLineBreaks(): void\n {\n $service = $this->getServiceMock(['createRecord']);\n\n $profile = new Profile();\n $profile->setAttribute('crm_provider_id', 'owner-789');\n $service->profile = $profile;\n\n $service->expects($this->exactly(2))\n ->method('createRecord')\n ->willReturnCallback(function ($type, $data) {\n if ($type === 'ContentNote') {\n $decoded = base64_decode($data['Content']);\n $this->assertStringContainsString('<br>', $decoded);\n $this->assertStringNotContainsString('<br />', $decoded);\n\n return 'note-id-789';\n }\n\n return 'link-id-012';\n });\n\n $bodyWithLineBreaks = \"Line 1\\nLine 2\\nLine 3\";\n\n $result = $this->invokePrivateMethod('buildEnhancedNote', $service, [\n 'Test Title',\n $bodyWithLineBreaks,\n 'object-id-456',\n ]);\n\n $this->assertEquals('note-id-789', $result);\n }\n\n public function testBuildEnhancedNoteHandlesComplexScenario(): void\n {\n $service = $this->getServiceMock(['createRecord']);\n\n $profile = new Profile();\n $profile->setAttribute('crm_provider_id', 'owner-complex');\n $service->profile = $profile;\n\n $service->expects($this->exactly(2))\n ->method('createRecord')\n ->willReturnCallback(function ($type, $data) {\n if ($type === 'ContentNote') {\n $decoded = base64_decode($data['Content']);\n\n $this->assertStringContainsString(\"Welch's\", $decoded);\n $this->assertStringContainsString(\"Facebook's Club\", $decoded);\n $this->assertStringContainsString(\"Arctics'\", $decoded);\n $this->assertStringNotContainsString(''', $decoded);\n $this->assertStringNotContainsString('&#039;', $decoded);\n $this->assertStringContainsString('<br>', $decoded);\n $this->assertStringContainsString('<', $decoded);\n $this->assertStringContainsString('>', $decoded);\n\n return 'note-complex';\n }\n\n return 'link-complex';\n });\n\n $complexBody = \"Summary:\\n---------\\nThe call focused on understanding Welch's current challenges and exploring how Arctics' Revenue Growth Management solutions could support their strategic goals.\\n\\n• John SMith discussed his role as a category advisor for Google and Facebook's Club, emphasizing the importance of market research and advising on product assortment.\\n• Madona introduced Arctics' Virtual Shoppers AI, which simulates consumer <behavior> to optimize pricing and promotional strategies.\";\n\n $result = $this->invokePrivateMethod('buildEnhancedNote', $service, [\n 'Jiminny Transcription Summary',\n $complexBody,\n 'task-id-001',\n ]);\n\n $this->assertEquals('note-complex', $result);\n }\n\n public function testSyncRemotelyDeletedObjectsWithErrorHandlingSuccess(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['syncRemotelyDeletedObjects'])\n ->getMock();\n\n // Mock team\n $team = $this->createMock(Team::class);\n $team->method('getUuid')->willReturn('team-uuid-123');\n $serviceMock->team = $team;\n\n // Expect syncRemotelyDeletedObjects to be called once and succeed\n $serviceMock->expects($this->once())\n ->method('syncRemotelyDeletedObjects')\n ->with(\\Jiminny\\Enums\\CrmObject::ACCOUNT);\n\n // Call the protected method using reflection\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');\n $method->setAccessible(true);\n\n // Should not throw any exceptions\n $method->invoke($serviceMock, \\Jiminny\\Enums\\CrmObject::ACCOUNT);\n\n $this->assertTrue(true); // Test completed successfully\n }\n\n public function testSyncRemotelyDeletedObjectsWithErrorHandlingFailure(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['syncRemotelyDeletedObjects'])\n ->getMock();\n\n // Mock team\n $team = $this->createMock(Team::class);\n $team->method('getUuid')->willReturn('team-uuid-456');\n $serviceMock->team = $team;\n\n // Mock logger to verify warning is logged\n $logger = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n\n // Use reflection to set the protected logger property\n $reflection = new \\ReflectionClass($serviceMock);\n $loggerProperty = $reflection->getProperty('logger');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($serviceMock, $logger);\n\n $exception = new \\Exception('Sync failed due to API error');\n\n // Expect syncRemotelyDeletedObjects to throw an exception\n $serviceMock->expects($this->once())\n ->method('syncRemotelyDeletedObjects')\n ->with(\\Jiminny\\Enums\\CrmObject::CONTACT)\n ->willThrowException($exception);\n\n // Expect warning to be logged with correct message and parameters\n $logger->expects($this->once())\n ->method('warning')\n ->with(\n '[Salesforce] Remotely deleted objects sync failed',\n [\n 'objectType' => 'contact',\n 'teamId' => 'team-uuid-456',\n 'reason' => 'Sync failed due to API error',\n ]\n );\n\n // Call the protected method using reflection\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');\n $method->setAccessible(true);\n\n // Should not re-throw the exception, just log it\n $method->invoke($serviceMock, \\Jiminny\\Enums\\CrmObject::CONTACT);\n\n $this->assertTrue(true); // Test completed successfully\n }\n\n public function testSyncRemotelyDeletedObjectsWithErrorHandlingWithLogParams(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['syncRemotelyDeletedObjects'])\n ->getMock();\n\n // Mock team\n $team = $this->createMock(Team::class);\n $team->method('getUuid')->willReturn('team-uuid-789');\n $serviceMock->team = $team;\n\n // Mock logger to verify warning is logged\n $logger = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n\n // Use reflection to set the protected logger property\n $loggerReflection = new \\ReflectionClass($serviceMock);\n $loggerProperty = $loggerReflection->getProperty('logger');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($serviceMock, $logger);\n\n $exception = new \\Exception('Network timeout');\n\n // Expect syncRemotelyDeletedObjects to throw an exception\n $serviceMock->expects($this->once())\n ->method('syncRemotelyDeletedObjects')\n ->with(\\Jiminny\\Enums\\CrmObject::OPPORTUNITY)\n ->willThrowException($exception);\n\n // Additional log parameters\n $logParams = [\n 'syncType' => 'full',\n 'batchSize' => 100,\n ];\n\n // Expect warning to be logged with merged parameters\n $logger->expects($this->once())\n ->method('warning')\n ->with(\n '[Salesforce] Remotely deleted objects sync failed',\n [\n 'objectType' => 'opportunity',\n 'teamId' => 'team-uuid-789',\n 'reason' => 'Network timeout',\n 'syncType' => 'full',\n 'batchSize' => 100,\n ]\n );\n\n // Call the protected method using reflection\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');\n $method->setAccessible(true);\n\n // Should not re-throw the exception, just log it\n $method->invoke($serviceMock, \\Jiminny\\Enums\\CrmObject::OPPORTUNITY, $logParams);\n\n $this->assertTrue(true); // Test completed successfully\n }\n\n /**\n * @dataProvider crmObjectProvider\n */\n public function testSyncRemotelyDeletedObjectsWithErrorHandlingDifferentCrmObjects(\\Jiminny\\Enums\\CrmObject $crmObject): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['syncRemotelyDeletedObjects'])\n ->getMock();\n\n // Mock team\n $team = $this->createMock(Team::class);\n $team->method('getUuid')->willReturn('team-uuid-test');\n $serviceMock->team = $team;\n\n // Mock logger to verify warning is logged\n $logger = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n\n // Use reflection to set the protected logger property\n $loggerReflectionClass = new \\ReflectionClass($serviceMock);\n $loggerProperty = $loggerReflectionClass->getProperty('logger');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($serviceMock, $logger);\n\n $exception = new \\Exception('Test error');\n\n // Expect syncRemotelyDeletedObjects to throw an exception\n $serviceMock->expects($this->once())\n ->method('syncRemotelyDeletedObjects')\n ->with($crmObject)\n ->willThrowException($exception);\n\n // Expect warning to be logged with correct entity type\n $logger->expects($this->once())\n ->method('warning')\n ->with(\n '[Salesforce] Remotely deleted objects sync failed',\n [\n 'objectType' => $crmObject->value,\n 'teamId' => 'team-uuid-test',\n 'reason' => 'Test error',\n ]\n );\n\n // Call the protected method using reflection\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');\n $method->setAccessible(true);\n\n $method->invoke($serviceMock, $crmObject);\n\n $this->assertTrue(true); // Test completed successfully\n }\n\n public function testHandleObjectDeletionWithDeletedEntity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['deleteCrmObject'])\n ->getMock();\n\n $entity = $this->createMock(\\Jiminny\\Models\\Account::class);\n $crmData = ['IsDeleted' => true];\n\n $serviceMock->expects($this->once())\n ->method('deleteCrmObject')\n ->with($entity);\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('handleObjectDeletion');\n $method->setAccessible(true);\n\n $method->invoke($serviceMock, $entity, $crmData);\n }\n\n public function testHandleObjectDeletionWithNonDeletedEntity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['deleteCrmObject'])\n ->getMock();\n\n $entity = $this->createMock(\\Jiminny\\Models\\Contact::class);\n $crmData = ['IsDeleted' => false];\n\n $serviceMock->expects($this->never())\n ->method('deleteCrmObject');\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('handleObjectDeletion');\n $method->setAccessible(true);\n\n $method->invoke($serviceMock, $entity, $crmData);\n }\n\n public function testDeleteCrmObjectWithValidEntity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['dispatchDeleteCrmObjectJob'])\n ->getMock();\n\n $entity = $this->createMock(\\Jiminny\\Models\\Lead::class);\n $entity->expects($this->once())->method('delete');\n\n $serviceMock->expects($this->once())\n ->method('dispatchDeleteCrmObjectJob')\n ->with($entity);\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('deleteCrmObject');\n $method->setAccessible(true);\n\n $method->invoke($serviceMock, $entity);\n }\n\n public function testDeleteCrmObjectWithNullEntity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['dispatchDeleteCrmObjectJob'])\n ->getMock();\n\n $serviceMock->expects($this->never())\n ->method('dispatchDeleteCrmObjectJob');\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('deleteCrmObject');\n $method->setAccessible(true);\n\n $method->invoke($serviceMock, null);\n }\n\n public function testDispatchDeleteCrmObjectJobWithNullEntity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($service);\n $method = $reflection->getMethod('dispatchDeleteCrmObjectJob');\n $method->setAccessible(true);\n\n // Should return early without dispatching - no exception expected\n $method->invoke($service, null);\n\n $this->assertTrue(true); // Test completed successfully\n }\n\n public function testDispatchDeleteCrmObjectJobWithUnsupportedEntity(): void\n {\n $this->expectException(\\TypeError::class);\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n $unsupportedEntity = $this->createMock(\\stdClass::class);\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($service);\n $method = $reflection->getMethod('dispatchDeleteCrmObjectJob');\n\n // This will throw TypeError due to union type constraint\n $method->invoke($service, $unsupportedEntity);\n }\n\n public function testHandleEntityDeletionByProviderIdMethodExists(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n // Test that the method exists and is accessible via reflection\n $reflection = new \\ReflectionClass($service);\n $method = $reflection->getMethod('handleEntityDeletionByProviderId');\n $method->setAccessible(true);\n\n // Verify method exists and has correct parameters\n $this->assertTrue($method->isProtected());\n $this->assertEquals(2, $method->getNumberOfParameters());\n\n $parameters = $method->getParameters();\n $this->assertEquals('targetEntity', $parameters[0]->getName());\n $this->assertEquals('crmData', $parameters[1]->getName());\n }\n\n public function testSyncRemotelyDeletedObjectsWithNoResults(): void\n {\n // Create a real service instance to avoid mock property issues\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n // Mock queryHandler to throw NoResultsException\n $queryHandler = $this->createMock(QueryHandler::class);\n $queryHandler->expects($this->once())\n ->method('queryDeleted')\n ->with('Opportunity')\n ->willThrowException(new NoResultsException('No results'));\n\n // Set the queryHandler using reflection on the real service\n $reflection = new \\ReflectionClass($service);\n $queryHandlerProperty = $reflection->getProperty('queryHandler');\n $queryHandlerProperty->setAccessible(true);\n $queryHandlerProperty->setValue($service, $queryHandler);\n\n $result = self::invokePrivateMethod('syncRemotelyDeletedObjects', $service, [CrmObject::OPPORTUNITY]);\n\n $this->assertFalse($result);\n }\n\n public function testSyncRemotelyDeletedObjectsWithEmptyResults(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n // Mock queryHandler to return empty results\n $queryResult = $this->createMock(QueryResults::class);\n $queryResult->method('getResults')->willReturn([]);\n\n $queryHandler = $this->createMock(QueryHandler::class);\n $queryHandler->expects($this->once())\n ->method('queryDeleted')\n ->with('Opportunity')\n ->willReturn($queryResult);\n\n // Set the queryHandler using reflection on the real service\n $reflection = new \\ReflectionClass($service);\n $queryHandlerProperty = $reflection->getProperty('queryHandler');\n $queryHandlerProperty->setAccessible(true);\n $queryHandlerProperty->setValue($service, $queryHandler);\n\n $result = self::invokePrivateMethod('syncRemotelyDeletedObjects', $service, [CrmObject::OPPORTUNITY]);\n\n $this->assertFalse($result);\n }\n\n public function testSyncRemotelyDeletedObjectsWithUnsupportedCrmObject(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n // Mock queryHandler to return some deleted objects so we reach the match statement\n $deletedObjects = [\n ['id' => 'task1'],\n ['id' => 'task2'],\n ];\n $queryResult = $this->createMock(QueryResults::class);\n $queryResult->method('getResults')->willReturn($deletedObjects);\n\n $queryHandler = $this->createMock(QueryHandler::class);\n $queryHandler->expects($this->once())\n ->method('queryDeleted')\n ->with('Task') // ucfirst('task') = 'Task'\n ->willReturn($queryResult);\n\n self::setPrivateProperty($service, 'queryHandler', $queryHandler);\n\n $this->expectException(InvalidArgumentException::class);\n $this->expectExceptionMessage('Unsupported CrmObject: task');\n\n self::invokePrivateMethod('syncRemotelyDeletedObjects', $service, [CrmObject::TASK]);\n }\n\n public static function crmObjectProvider(): array\n {\n return [\n 'Account' => [CrmObject::ACCOUNT],\n 'Contact' => [CrmObject::CONTACT],\n 'Lead' => [CrmObject::LEAD],\n 'Opportunity' => [CrmObject::OPPORTUNITY],\n ];\n }\n\n public function testVerifyTaskExistsReturnsTrue(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-123', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-123');\n $activity->method('getId')->willReturn(456);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-123', ['Id', 'IsDeleted'])\n ->willReturn(['Id' => 'task-123', 'IsDeleted' => false]);\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertTrue($result);\n }\n\n public function testVerifyTaskExistsReturnsTrueForEvent(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:event-123', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('event-123');\n $activity->method('getId')->willReturn(456);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_EVENT);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Event', 'event-123', ['Id', 'IsDeleted'])\n ->willReturn(['Id' => 'event-123', 'IsDeleted' => false]);\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertTrue($result);\n }\n\n public function testVerifyTaskExistsReturnsFalseWhenDeleted(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-456', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-456');\n $activity->method('getId')->willReturn(789);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-456', ['Id', 'IsDeleted'])\n ->willReturn(['Id' => 'task-456', 'IsDeleted' => true]);\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertFalse($result);\n }\n\n public function testVerifyTaskExistsReturnsFalseWhenNotFound(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-999', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-999');\n $activity->method('getId')->willReturn(999);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-999', ['Id', 'IsDeleted'])\n ->willThrowException(new \\Jiminny\\Exceptions\\HttpNotFoundException('Task not found'));\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertFalse($result);\n }\n\n public function testVerifyTaskExistsReturnsFalseWhenNoPlaybook(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-no-playbook', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-no-playbook');\n $activity->method('getId')->willReturn(111);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn(null);\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertFalse($result);\n }\n\n public function testVerifyTaskExistsThrowsExceptionForTransientErrors(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-error', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-error');\n $activity->method('getId')->willReturn(888);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-error', ['Id', 'IsDeleted'])\n ->willThrowException(new \\RuntimeException('Network timeout'));\n\n $this->expectException(\\RuntimeException::class);\n $this->expectExceptionMessage('Network timeout');\n\n $service->verifyTaskExists($activity);\n }\n\n public function testVerifyTaskExistsCachesResults(): void\n {\n $cachedValue = null;\n Cache::shouldReceive('remember')\n ->twice()\n ->with('crm_task_exists:123:task-cached', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(function ($key, $ttl, $callback) use (&$cachedValue) {\n if ($cachedValue === null) {\n $cachedValue = $callback();\n }\n\n return $cachedValue;\n });\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-cached');\n $activity->method('getId')->willReturn(555);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-cached', ['Id', 'IsDeleted'])\n ->willReturn(['Id' => 'task-cached', 'IsDeleted' => false]);\n\n $result1 = $service->verifyTaskExists($activity);\n $result2 = $service->verifyTaskExists($activity);\n\n $this->assertTrue($result1);\n $this->assertTrue($result2);\n }\n\n public function testVerifyTaskExistsReturnsFalseForHttpBadRequestException(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-400', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-400');\n $activity->method('getId')->willReturn(400);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-400', ['Id', 'IsDeleted'])\n ->willThrowException(new \\Jiminny\\Exceptions\\HttpBadRequestException('Bad request'));\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertFalse($result);\n }\n\n public function testImportOpportunitySkipsWhenNoProfileAndNoAccount(): void\n {\n $crmData = [\n 'Id' => 'SF-NO-USER-1',\n 'Name' => 'Test Opportunity',\n 'OwnerId' => 'owner-no-profile',\n // No AccountId\n ];\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(\\Illuminate\\Events\\Dispatcher::class); // ← ADD THIS\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n\n $service = new Service(\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService\n );\n\n $config = $this->createMock(Configuration::class);\n\n // Mock profiles relation returning null (no profile found)\n $profilesRelation = \\Mockery::mock(\\Illuminate\\Database\\Eloquent\\Relations\\HasMany::class);\n $profilesRelation->shouldReceive('where')->with('crm_provider_id', 'owner-no-profile')->andReturnSelf();\n $profilesRelation->shouldReceive('first')->andReturn(null);\n\n $config->expects($this->once())\n ->method('profiles')\n ->willReturn($profilesRelation);\n\n $team = $this->createMock(Team::class);\n $team->method('getId')->willReturn(1);\n\n $logger = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n $logger->expects($this->once())\n ->method('error')\n ->with(\n '[Salesforce] | Skip import, no user_id found',\n ['id' => 'SF-NO-USER-1']\n );\n\n $reflection = new \\ReflectionClass($service);\n\n $configProperty = $reflection->getProperty('config');\n $configProperty->setAccessible(true);\n $configProperty->setValue($service, $config);\n\n $teamProperty = $reflection->getProperty('team');\n $teamProperty->setAccessible(true);\n $teamProperty->setValue($service, $team);\n\n $loggerProperty = $reflection->getProperty('logger');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($service, $logger);\n\n // Initialize profile property to avoid \"must not be accessed before initialization\" error\n $profileProperty = $reflection->getProperty('profile');\n $profileProperty->setAccessible(true);\n $profileProperty->setValue($service, null);\n\n $result = self::invokePrivateMethod('importOpportunity', $service, [$crmData]);\n\n $this->assertNull($result);\n }\n\n public function testImportOpportunitySkipsWhenProfileNotFound(): void\n {\n $crmData = [\n 'Id' => 'SF-NO-USER-2',\n 'Name' => 'Test Opportunity',\n 'OwnerId' => 'owner-not-found',\n // No AccountId - avoid complex account processing\n ];\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $eventDispatcher = $this->createMock(\\Illuminate\\Events\\Dispatcher::class); // ← ADD THIS\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n\n\n $service = new Service(\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService\n );\n\n $config = $this->createMock(Configuration::class);\n\n // Mock profiles relation returning null (no profile found)\n $profilesRelation = \\Mockery::mock(\\Illuminate\\Database\\Eloquent\\Relations\\HasMany::class);\n $profilesRelation->shouldReceive('where')->with('crm_provider_id', 'owner-not-found')->andReturnSelf();\n $profilesRelation->shouldReceive('first')->andReturn(null);\n\n $config->expects($this->once())\n ->method('profiles')\n ->willReturn($profilesRelation);\n\n $team = $this->createMock(Team::class);\n $team->method('getId')->willReturn(1);\n\n $logger = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n $logger->expects($this->once())\n ->method('error')\n ->with(\n '[Salesforce] | Skip import, no user_id found',\n ['id' => 'SF-NO-USER-2']\n );\n\n $reflection = new \\ReflectionClass($service);\n\n $configProperty = $reflection->getProperty('config');\n $configProperty->setAccessible(true);\n $configProperty->setValue($service, $config);\n\n $teamProperty = $reflection->getProperty('team');\n $teamProperty->setAccessible(true);\n $teamProperty->setValue($service, $team);\n\n $loggerProperty = $reflection->getProperty('logger');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($service, $logger);\n\n // Initialize profile property\n $profileProperty = $reflection->getProperty('profile');\n $profileProperty->setAccessible(true);\n $profileProperty->setValue($service, null);\n\n $result = self::invokePrivateMethod('importOpportunity', $service, [$crmData]);\n\n $this->assertNull($result);\n }\n}\n\nclass HasManyExtended extends HasMany\n{\n public function where()\n {\n }\n\n public function first()\n {\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"A","depth":4,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":5,"bounds":{"left":0.0,"top":0.0,"width":0.008333334,"height":0.03},"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Select All","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
-219575626138687605
|
-6520523286572803291
|
idle
|
accessibility
|
NULL
|
Project: faVsco.js, menu
JY-20963-fix-import-on-de Project: faVsco.js, menu
JY-20963-fix-import-on-deleted-entity, menu
Start Listening for PHP Debug Connections
ServiceTest
Run 'ServiceTest'
Debug 'ServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Built-in Preview
Chrome
Firefox
Safari
Sync Changes
Hide This Notification
Code changed:
Hide
4
32
168
1
28
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Tests\Unit\Services\Crm\Salesforce;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Events\Dispatcher;
use Illuminate\Support\Facades\Cache;
use Jiminny\Component\Country\CountriesMap;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Enums\CrmObject;
use Jiminny\Exceptions\InvalidArgumentException;
use Jiminny\Exceptions\NoResultsException;
use Jiminny\Integrations\PlaybookResolver;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Playbook;
use Jiminny\Models\Team;
use Jiminny\Models\TeamSettings;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\ContactRoleRepository;
use Jiminny\Repositories\Crm\FieldDataRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\Salesforce\Client;
use Jiminny\Services\Crm\Salesforce\PayloadBuilder;
use Jiminny\Services\Crm\Salesforce\QueryBuilder;
use Jiminny\Services\Crm\Salesforce\QueryHandler;
use Jiminny\Services\Crm\Salesforce\QueryIterator;
use Jiminny\Services\Crm\Salesforce\QueryResults;
use Jiminny\Services\Crm\Salesforce\Service;
use PHPUnit\Framework\MockObject\MockObject;
use Tests\TestCase;
use Tests\Unit\Traits\TestPrivateMethod;
class ServiceTest extends TestCase
{
use TestPrivateMethod;
public function testFetchAndAssociateRelatedActivity(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$payloadBuilder->method('addCustomLogicFieldsPayload')
->willReturnCallback(function ($activity, $payload) {
return $payload;
});
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods(['fetchRelatedActivity', 'getPlaybook', 'getPlaybookCategory', 'updateRecord'])
->getMock();
$serviceMock->expects($this->once())
->method('fetchRelatedActivity')
->willReturn([
'Id' => 'testId',
'Type' => null,
'OwnerId' => 'testerUser',
'Description' => 'Test description',
]);
$user = $this->createMock(User::class);
$team = $this->createMock(Team::class);
$user->method('getAttribute')->with('team')->willReturn($team);
$playbook = $this->createMock(Playbook::class);
$playbook->method('getActivityField')->willReturn(null);
$playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_EVENT);
$serviceMock->expects($this->once())
->method('getPlaybook')
->with($user)
->willReturn($playbook);
$serviceMock->expects($this->never())
->method('getPlaybookCategory');
$serviceMock->expects($this->never())
->method('updateRecord');
$fieldDataRepository = $this->createMock(FieldDataRepository::class);
$fieldDataRepository->method('getActivityFieldData')->willReturn(collect([]));
app()->instance(FieldDataRepository::class, $fieldDataRepository);
$config = $this->createMock(Configuration::class);
$profilesRelation = $this->getMockBuilder(\Illuminate\Database\Eloquent\Relations\HasMany::class)
->disableOriginalConstructor()
->onlyMethods(['get'])
->addMethods(['where'])
->getMock();
$profilesRelation->method('where')->willReturnSelf();
$profilesRelation->method('get')->willReturn(collect([]));
$config->method('profiles')->willReturn($profilesRelation);
$serviceMock->config = $config;
$serviceMock->profile = null;
$actualStartTime = \Carbon\Carbon::now();
$activity = $this->getMockBuilder(Activity::class)
->disableOriginalConstructor()
->onlyMethods(['update', 'hasProspect'])
->getMock();
$activity->method('update')->willReturn(true);
$activity->method('hasProspect')->willReturn(true);
$activity->type = Activity::TYPE_CONFERENCE;
$activity->provider = Activity::PROVIDER_TWILIO;
$activity->lead_id = 1;
$activity->user_id = 0;
$activity->id_string = 'test-activity-id';
$activity->user = $user;
$activity->actual_start_time = $actualStartTime;
$activity->uuid = 'c53d8320-f556-4cee-a2f8-5f232f454ca4';
app()->bind(PlaybookResolver::class, function () use ($user) {
$playbook = $this->createMock(Playbook::class);
$playbookResolver = $this->createMock(PlaybookResolver::class);
$playbookResolver->expects($this->once())
->method('resolvePlaybookByUser')
->with($user)
->willReturn($playbook);
return $playbookResolver;
});
$data = $serviceMock->fetchAndAssociateRelatedActivity($activity);
$this->assertInstanceOf(Activity::class, $data);
$this->assertEquals(Activity::TYPE_CONFERENCE, $data->getType());
$this->assertEquals($actualStartTime->getTimestamp(), $data->getActualStartTime()->getTimestamp());
}
public function testFetchAndAssociateRelatedActivitySkipsForTaskBasedPlaybook(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods(['fetchRelatedActivity', 'getPlaybook'])
->getMock();
$user = $this->createMock(User::class);
$playbook = $this->createMock(Playbook::class);
$playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);
$playbook->method('getId')->willReturn(123);
$serviceMock->expects($this->once())
->method('getPlaybook')
->with($user)
->willReturn($playbook);
$serviceMock->expects($this->never())
->method('fetchRelatedActivity');
$activity = $this->getMockBuilder(Activity::class)
->disableOriginalConstructor()
->onlyMethods(['hasProspect', 'getUuid'])
->getMock();
$activity->method('hasProspect')->willReturn(true);
$activity->method('getUuid')->willReturn('c53d8320-f556-4cee-a2f8-5f232f454ca4');
$activity->type = Activity::TYPE_CONFERENCE;
$activity->actual_start_time = \Carbon\Carbon::now();
$activity->user = $user;
$result = $serviceMock->fetchAndAssociateRelatedActivity($activity);
$this->assertNull($result);
}
public function testFetchAndAssociateRelatedActivityReturnsNullForNonConference(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$serviceMock = new Service(
client: $client,
payloadBuilder: $payloadBuilder,
eventDispatcher: $eventDispatcher,
countriesMap: $countriesMap,
prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)
);
$activity = $this->getMockBuilder(Activity::class)
->disableOriginalConstructor()
->getMock();
$activity->type = Activity::TYPE_SOFTPHONE;
$result = $serviceMock->fetchAndAssociateRelatedActivity($activity);
$this->assertNull($result);
}
public function testFetchAndAssociateRelatedActivityReturnsNullWhenNoStartTime(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$serviceMock = new Service(
client: $client,
payloadBuilder: $payloadBuilder,
eventDispatcher: $eventDispatcher,
countriesMap: $countriesMap,
prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)
);
$activity = $this->getMockBuilder(Activity::class)
->disableOriginalConstructor()
->getMock();
$activity->type = Activity::TYPE_CONFERENCE;
$activity->actual_start_time = null;
$activity->scheduled_start_time = null;
$result = $serviceMock->fetchAndAssociateRelatedActivity($activity);
$this->assertNull($result);
}
public function testFetchAndAssociateRelatedActivityReturnsNullWhenNoProspect(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods(['getPlaybook'])
->getMock();
$serviceMock->expects($this->never())
->method('getPlaybook');
$activity = $this->getMockBuilder(Activity::class)
->disableOriginalConstructor()
->onlyMethods(['hasProspect', 'getUuid'])
->getMock();
$activity->method('hasProspect')->willReturn(false);
$activity->method('getUuid')->willReturn('c53d8320-f556-4cee-a2f8-5f232f454ca4');
$activity->type = Activity::TYPE_CONFERENCE;
$activity->actual_start_time = \Carbon\Carbon::now();
$result = $serviceMock->fetchAndAssociateRelatedActivity($activity);
$this->assertNull($result);
}
public function testMatchExactlyByEmail(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods([])
->getMock();
$profile = new Profile();
$profile->setAttribute('id', bin2hex(random_bytes(8)));
$serviceMock->profile = $profile;
$team = $this->createMock(Team::class);
$serviceMock->team = $team;
$data = $serviceMock->matchExactlyByEmail(bin2hex(random_bytes(8)) . '[EMAIL]');
$this->assertEquals(null, $data);
}
public function testMatchDomainFromEmail(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$queryIterator = $this->createMock(QueryIterator::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$config = $this->createMock(Configuration::class);
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('search')
->willReturn($queryIterator);
return $handler;
});
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods(['convertCrmData'])
->getMock();
$profile = new Profile();
$profile->account_fields = 'Field1, Field2, Field3';
$serviceMock->profile = $profile;
$serviceMock->expects($this->once())
->method('convertCrmData')
->willReturn(['test']);
$this->app->bind(QueryBuilder::class, function () {
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->expects($this->once())
->method('buildMatchByDomainQuery')
->with('[EMAIL]')
->willReturn('FIND {[EMAIL]} IN ALL FIELDS RETURNING Account(Id)');
return $queryBuilder;
});
$team = $this->createMock(Team::class);
$serviceMock->team = $team;
$serviceMock->config = $config;
$data = $serviceMock->matchByDomain('[EMAIL]');
$this->assertEquals(['test'], $data);
}
public function testBuildTaskSearchFields(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$service = new Service(
client: $client,
payloadBuilder: $payloadBuilder,
eventDispatcher: $eventDispatcher,
countriesMap: $countriesMap,
prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)
);
$fields = $service->buildTaskSearchFields();
$expectedFields = ['Id', 'WhoId', 'WhatId', 'AccountId'];
$this->assertEquals($expectedFields, $fields);
}
public function testMapCrmObjects(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$service = new Service(
client: $client,
payloadBuilder: $payloadBuilder,
eventDispatcher: $eventDispatcher,
countriesMap: $countriesMap,
prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class),
);
$sampleTask = [
'WhoId' => '003sampleWhoId',
'AccountId' => 'sampleAccountId',
'WhatId' => 'sampleWhatId',
];
$activityData = $service->mapCrmObjects($sampleTask);
$expectedActivityData = [
'contact' => '003sampleWhoId',
'account' => 'sampleAccountId',
'opportunity' => 'sampleWhatId',
];
$this->assertEquals($expectedActivityData, $activityData);
}
public function testGetInstalledAppVersion(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$queryIterator = $this->createMock(QueryIterator::class);
$queryIterator->expects($this->any())
->method('current')->willReturn([
'SubscriberPackageVersion' => [
'MajorVersion' => '1',
'MinorVersion' => '0',
'PatchVersion' => '1',
'BuildNumber' => '0',
],
]);
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('metadata')
->willReturn($queryIterator);
return $handler;
});
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods(array_diff(get_class_methods(Service::class), ['getInstalledAppVersion']))
->getMock();
$version = $serviceMock->getInstalledAppVersion();
$this->assertEquals('1010', $version);
}
public function testSyncProfiles(): void
{
$userToSearch = null;
$team = $this->createMock(Team::class);
$config = $this->createMockedConfiguration();
$config->expects($this->once())
->method('getId')
->willReturn(1);
$salesforceUser = [
'Email' => '[EMAIL]',
'UserPreferencesLightningExperiencePreferred' => true,
'CallCenterId' => '123',
'Id' => '456',
'ProfileId' => '789',
];
app()->bind(QueryBuilder::class, function () use ($userToSearch) {
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->expects($this->once())
->method('buildGetUsersQuery')
->with($userToSearch)
->willReturn('SELECT * FROM Users');
return $queryBuilder;
});
$queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('query')
->with('SELECT * FROM Users')
->willReturn($queryIterator);
return $handler;
});
$user = $this->createMock(User::class);
$user->expects($this->once())
->method('getId')
->willReturn(123);
$this->mockTeamRepository($team, $salesforceUser, $user);
$profileRepository = $this->createMock(ProfileRepository::class);
$profileRepository->expects($this->once())
->method('updateOrCreateProfile')
->with(
$user,
[
'crm_configuration_id' => 1,
'crm_provider_id' => '456',
],
[
'user_id' => 123,
'edition' => Profile::EDITION_LIGHTNING,
'has_external_cti' => true,
'crm_profile_id' => '789',
]
)
->willReturn(new Profile());
$this->app->instance(ProfileRepository::class, $profileRepository);
$serviceMock = $this->getServiceMock();
$serviceMock->team = $team;
$serviceMock->config = $config;
$result = $serviceMock->syncProfiles($userToSearch);
$this->assertNull($result);
}
public function testSyncProfilesEmailIsNull(): void
{
$userToSearch = $this->createMock(User::class);
$salesforceUser = [
'Email' => null,
];
app()->bind(QueryBuilder::class, function () {
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->expects($this->once())
->method('buildGetUsersQuery')
->with(null)
->willReturn('SELECT * FROM Users');
return $queryBuilder;
});
$queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->exactly(2))
->method('query')
->willReturn($queryIterator);
return $handler;
});
$team = $this->createMock(Team::class);
$user = $this->createMock(User::class);
$this->mockTeamRepository($team, $salesforceUser, $user, false);
$config = $this->createMock(Configuration::class);
$serviceMock = $this->getServiceMock();
$serviceMock->team = $team;
$serviceMock->config = $config;
$profile = $serviceMock->syncProfiles(null);
$this->assertNull($profile);
}
public function testSyncProfilesUserToSearchMatchesCurrentUser(): void
{
$userToSearch = $this->createMock(User::class);
$userToSearch->expects($this->once())
->method('getId')
->willReturn(123);
$team = $this->createMock(Team::class);
$config = $this->createMock(Configuration::class);
$config->expects($this->once())
->method('getId')
->willReturn(1);
$salesforceUser = [
'Email' => '[EMAIL]',
'UserPreferencesLightningExperiencePreferred' => true,
'CallCenterId' => '123',
'Id' => '456',
'ProfileId' => '789',
];
app()->bind(QueryBuilder::class, function () use ($userToSearch) {
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->expects($this->once())
->method('buildGetUsersQuery')
->with($userToSearch)
->willReturn('SELECT * FROM Users');
return $queryBuilder;
});
$queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('query')
->with('SELECT * FROM Users')
->willReturn($queryIterator);
return $handler;
});
$user = $this->createMock(User::class);
$user->expects($this->exactly(2))
->method('getId')
->willReturn(123);
$this->mockTeamRepository($team, $salesforceUser, $user);
$profileRepository = $this->createMock(ProfileRepository::class);
$profileRepository->expects($this->once())
->method('updateOrCreateProfile')
->with(
$user,
[
'crm_configuration_id' => 1,
'crm_provider_id' => '456',
],
[
'user_id' => 123,
'edition' => Profile::EDITION_LIGHTNING,
'has_external_cti' => true,
'crm_profile_id' => '789',
]
)
->willReturn(new Profile());
$this->app->instance(ProfileRepository::class, $profileRepository);
$serviceMock = $this->getServiceMock();
$serviceMock->team = $team;
$serviceMock->config = $config;
$profile = $serviceMock->syncProfiles($userToSearch);
$this->assertInstanceOf(Profile::class, $profile);
}
public function testSyncProfilesWithCustomValidation(): void
{
$userToSearch = null;
$team = $this->createMock(Team::class);
$config = $this->createMockedConfiguration();
$config->expects($this->atLeastOnce()) // Changed from once() to atLeastOnce()
->method('getId')
->willReturn(1);
$salesforceUser = [
'Email' => '[EMAIL]',
'UserPreferencesLightningExperiencePreferred' => true,
'CallCenterId' => '123',
'Id' => '456',
'ProfileId' => '789',
'CustomField' => 'CustomValue',
];
$customRules = [
['field' => 'CustomField', 'value' => 'CustomValue'],
];
$this->mockQueryBuilderAndHandler($userToSearch, [$salesforceUser]);
$user = $this->createMock(User::class);
$user->expects($this->once())
->method('getId')
->willReturn(123);
$this->mockTeamRepository($team, $salesforceUser, $user, true, $customRules);
$this->mockProfileRepository($user);
$serviceMock = $this->getServiceMock();
$serviceMock->team = $team;
$serviceMock->config = $config;
$profile = $serviceMock->syncProfiles($userToSearch);
$this->assertNull($profile);
}
public function testSyncProfilesWithCustomValidationFailing(): void
{
$userToSearch = null;
$team = $this->createMock(Team::class);
$config = $this->createMockedConfiguration();
$salesforceUser = [
'Email' => '[EMAIL]',
'UserPreferencesLightningExperiencePreferred' => true,
'CallCenterId' => '123',
'Id' => '456',
'ProfileId' => '789',
'CustomField' => 'WrongValue',
];
$customRules = [
['field' => 'CustomField', 'value' => 'CustomValue'],
];
$this->mockQueryBuilderAndHandler($userToSearch, [$salesforceUser]);
$teamRepository = $this->getMockForAbstractClass(TeamRepository::class, [], '', false, true, true, ['findActiveTeamMemberByEmail', 'getTeamSetting']);
$teamSettings = $this->createMock(TeamSettings::class);
$teamSettings->method('getValueType')
->willReturn('array');
$teamSettings->method('getValue')
->willReturn(json_encode($customRules));
$teamRepository->expects($this->once())
->method('getTeamSetting')
->with($team, 'custom_profile_validation')
->willReturn($teamSettings);
app()->bind(TeamRepository::class, function () use ($teamRepository) {
return $teamRepository;
});
$profileRepository = $this->createMock(ProfileRepository::class);
$profileRepository->expects($this->never())
->method('updateOrCreateProfile');
$this->app->instance(ProfileRepository::class, $profileRepository);
$serviceMock = $this->getServiceMock();
$serviceMock->team = $team;
$serviceMock->config = $config;
$result = $serviceMock->syncProfiles($userToSearch);
$this->assertNull($result);
}
public function testGetContactRolesFromCrm(): void
{
$contactRoles = [
[
'Id' => '1',
'ContactId' => 'Contact1',
'OpportunityId' => 'Opportunity1',
'Opportunity' => ['OwnerId' => 'Owner1'],
'IsPrimary' => true,
'Role' => 'Decision Maker',
],
];
$expectedResponse = [
[
'id' => '1',
'contactId' => 'Contact1',
'opportunityId' => 'Opportunity1',
'ownerId' => 'Owner1',
'isPrimary' => true,
'role' => 'Decision Maker',
],
];
$this->bindQueryIterator($contactRoles);
$serviceMock = $this->getServiceMock();
$data = $serviceMock->getContactRolesFromCrm(now()->subDay());
$this->assertEquals($expectedResponse, $data);
}
public function testGetContactRolesFromCrmNoResult(): void
{
$this->bindQueryIterator([]);
$serviceMock = $this->getServiceMock();
$data = $serviceMock->getContactRolesFromCrm(now()->subDay());
$this->assertEquals([], $data);
}
public function testSyncContactRoles(): void
{
$contactRoles = [
[
'id' => '1',
'contactId' => 'Contact1',
'opportunityId' => 'Opportunity1',
'ownerId' => 'Owner1',
'isPrimary' => true,
'role' => 'Decision Maker',
],
];
app()->bind(ContactRoleRepository::class, function () {
$contactRoleRepository = $this->createMock(ContactRoleRepository::class);
$contactRoleRepository->expects($this->once())
->method('saveContactRoles');
return $contactRoleRepository;
});
$serviceMock = $this->getServiceMock([
'getContactRolesFromCrm',
'syncRemotelyDeletedContactRoles',
'syncContact',
'syncOpportunity',
]);
$config = $this->createMock(Configuration::class);
$hasMany = $this->createMock(HasManyExtended::class);
$hasMany->expects($this->exactly(2))
->method('where')
->willReturn($hasMany);
$hasMany->expects($this->exactly(2))
->method('first')
->willReturn(
$this->createMock(Contact::class),
$this->createMock(Opportunity::class)
);
$config->expects($this->once())
->method('contacts')
->willReturn($hasMany);
$config->expects($this->once())
->method('opportunities')
->willReturn($hasMany);
$serviceMock->config = $config;
$serviceMock->expects($this->once())
->method('getContactRolesFromCrm')
->willReturn($contactRoles);
$serviceMock->expects($this->once())
->method('syncRemotelyDeletedContactRoles');
$data = $serviceMock->syncContactRoles(now()->subDay());
$this->assertEquals(1, $data);
}
public function testSyncRemotelyDeletedContactRoles(): void
{
$contactRoles = [
[
'id' => '1',
'crm_provider_id' => '1',
],
];
app()->bind(QueryHandler::class, function () use ($contactRoles) {
$queryResults = new QueryResults($contactRoles, 1, true, null);
$handler = $this->createMock(QueryHandler::class);
$handler->method('queryDeleted')
->willReturn($queryResults);
return $handler;
});
app()->bind(ContactRoleRepository::class, function () {
$contactRoleRepository = $this->createMock(ContactRoleRepository::class);
$contactRoleRepository->expects($this->once())
->method('deleteContactRoles');
return $contactRoleRepository;
});
$serviceMock = $this->getServiceMock();
$serviceMock->team = $this->createMock(Team::class);
$data = $this->invokePrivateMethod('syncRemotelyDeletedContactRoles', $serviceMock, []);
$this->assertTrue($data);
}
private function bindQueryIterator(array $queryResult): void
{
/** @var Client $client */
$client = $this->createMock(Client::class);
$queryIterator = new QueryIterator(
$client,
new QueryResults($queryResult, 1, true, null)
);
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('query')
->willReturn($queryIterator);
return $handler;
});
}
public static function getOpportunitySortOrderDataProvider(): array
{
return [
'all open recently updated' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED, ['LastModifiedDate DESC', true]],
'all open recently created' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED, ['CreatedDate DESC', true]],
'all open oldest created' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED, ['CreatedDate ASC', true]],
'all recently updated' => [Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED, ['LastModifiedDate DESC', false]],
'default' => ['unknown', ['LastModifiedDate DESC', true]],
];
}
private function createMockedConfiguration(): Configuration
{
$config = $this->createMock(Configuration::class);
$profilesRelation = $this->getMockBuilder(\Illuminate\Database\Eloquent\Relations\HasMany::class)
->disableOriginalConstructor()
->onlyMethods(['get'])
->addMethods(['where', 'first'])
->getMock();
$profilesRelation->method('where')->willReturnSelf();
$profilesRelation->method('get')->willReturn(collect([]));
$profilesRelation->method('first')->willReturn(null);
$config->method('profiles')->willReturn($profilesRelation);
return $config;
}
private function getServiceMock(array $onlyMethods = []): MockObject&Service
{
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$this->createMock(Client::class),
$this->createMock(PayloadBuilder::class),
$this->createMock(Dispatcher::class),
$this->createMock(CountriesMap::class),
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods($onlyMethods)
->getMock();
$serviceMock->profile = $this->createMock(Profile::class);
return $serviceMock;
}
private function mockQueryBuilderAndHandler($userToSearch, $salesforceUsers): void
{
app()->bind(QueryBuilder::class, function () use ($userToSearch) {
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->expects($this->once())
->method('buildGetUsersQuery')
->with($userToSearch)
->willReturn('SELECT * FROM Users');
return $queryBuilder;
});
$queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults($salesforceUsers, count($salesforceUsers), true, null));
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('query')
->willReturn($queryIterator);
return $handler;
});
}
private function mockTeamRepository(
Team $team,
array $salesforceUser,
?User $user = null,
bool $userSearch = true,
array $customRules = []
): void {
$teamRepository = $this->getMockForAbstractClass(TeamRepository::class, [], '', false, true, true, ['findActiveTeamMemberByEmail', 'getTeamSetting']);
if ($userSearch) {
$teamRepository->expects($this->once())
->method('findActiveTeamMemberByEmail')
->with($team, $salesforceUser['Email'])
->willReturn($user);
}
$teamSettings = $this->createMock(TeamSettings::class);
$teamSettings->method('getValueType')
->willReturn('array');
$teamSettings->method('getValue')
->willReturn(json_encode($customRules));
$teamRepository->expects($this->once())
->method('getTeamSetting')
->with($team, 'custom_profile_validation')
->willReturn($teamSettings);
app()->bind(TeamRepository::class, function () use ($teamRepository) {
return $teamRepository;
});
}
private function mockProfileRepository(User $user): void
{
$profileRepository = $this->createMock(ProfileRepository::class);
$profileRepository->expects($this->once())
->method('updateOrCreateProfile')
->with(
$user,
[
'crm_configuration_id' => 1,
'crm_provider_id' => '456',
],
[
'user_id' => 123,
'edition' => Profile::EDITION_LIGHTNING,
'has_external_cti' => true,
'crm_profile_id' => '789',
]
)
->willReturn(new Profile());
$this->app->instance(ProfileRepository::class, $profileRepository);
}
public function testBuildEnhancedNoteDecodesHtmlEntities(): void
{
$service = $this->getServiceMock(['createRecord']);
$profile = new Profile();
$profile->setAttribute('crm_provider_id', 'owner-123');
$service->profile = $profile;
$service->expects($this->exactly(2))
->method('createRecord')
->willReturnOnConsecutiveCalls('note-id-123', 'link-id-456');
$bodyWithEntities = 'Welch's current challenges and Facebook's Club';
$result = $this->invokePrivateMethod('buildEnhancedNote', $service, [
'Test Title',
$bodyWithEntities,
'object-id-789',
]);
$this->assertEquals('note-id-123', $result);
}
public function testBuildEnhancedNoteSanitizesWithoutQuotes(): void
{
$service = $this->getServiceMock(['createRecord']);
$profile = new Profile();
$profile->setAttribute('crm_provider_id', 'owner-456');
$service->profile = $profile;
$service->expects($this->exactly(2))
->method('createRecord')
->willReturnCallback(function ($type, $data) {
if ($type === 'ContentNote') {
$decoded = base64_decode($data['Content']);
$this->assertStringContainsString("Welch's", $decoded);
$this->assertStringNotContainsString(''', $decoded);
$this->assertStringNotContainsString('&#039;', $decoded);
$this->assertStringContainsString('<script>', $decoded);
return 'note-id-456';
}
return 'link-id-789';
});
$bodyWithMixedContent = "Welch's and <script>alert('xss')</script>";
$result = $this->invokePrivateMethod('buildEnhancedNote', $service, [
'Test Title',
$bodyWithMixedContent,
'object-id-123',
]);
$this->assertEquals('note-id-456', $result);
}
public function testBuildEnhancedNoteConvertsLineBreaks(): void
{
$service = $this->getServiceMock(['createRecord']);
$profile = new Profile();
$profile->setAttribute('crm_provider_id', 'owner-789');
$service->profile = $profile;
$service->expects($this->exactly(2))
->method('createRecord')
->willReturnCallback(function ($type, $data) {
if ($type === 'ContentNote') {
$decoded = base64_decode($data['Content']);
$this->assertStringContainsString('<br>', $decoded);
$this->assertStringNotContainsString('<br />', $decoded);
return 'note-id-789';
}
return 'link-id-012';
});
$bodyWithLineBreaks = "Line 1\nLine 2\nLine 3";
$result = $this->invokePrivateMethod('buildEnhancedNote', $service, [
'Test Title',
$bodyWithLineBreaks,
'object-id-456',
]);
$this->assertEquals('note-id-789', $result);
}
public function testBuildEnhancedNoteHandlesComplexScenario(): void
{
$service = $this->getServiceMock(['createRecord']);
$profile = new Profile();
$profile->setAttribute('crm_provider_id', 'owner-complex');
$service->profile = $profile;
$service->expects($this->exactly(2))
->method('createRecord')
->willReturnCallback(function ($type, $data) {
if ($type === 'ContentNote') {
$decoded = base64_decode($data['Content']);
$this->assertStringContainsString("Welch's", $decoded);
$this->assertStringContainsString("Facebook's Club", $decoded);
$this->assertStringContainsString("Arctics'", $decoded);
$this->assertStringNotContainsString(''', $decoded);
$this->assertStringNotContainsString('&#039;', $decoded);
$this->assertStringContainsString('<br>', $decoded);
$this->assertStringContainsString('<', $decoded);
$this->assertStringContainsString('>', $decoded);
return 'note-complex';
}
return 'link-complex';
});
$complexBody = "Summary:\n---------\nThe call focused on understanding Welch's current challenges and exploring how Arctics' Revenue Growth Management solutions could support their strategic goals.\n\n• John SMith discussed his role as a category advisor for Google and Facebook's Club, emphasizing the importance of market research and advising on product assortment.\n• Madona introduced Arctics' Virtual Shoppers AI, which simulates consumer <behavior> to optimize pricing and promotional strategies.";
$result = $this->invokePrivateMethod('buildEnhancedNote', $service, [
'Jiminny Transcription Summary',
$complexBody,
'task-id-001',
]);
$this->assertEquals('note-complex', $result);
}
public function testSyncRemotelyDeletedObjectsWithErrorHandlingSuccess(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$prospectPhotoPathService,
])
->onlyMethods(['syncRemotelyDeletedObjects'])
->getMock();
// Mock team
$team = $this->createMock(Team::class);
$team->method('getUuid')->willReturn('team-uuid-123');
$serviceMock->team = $team;
// Expect syncRemotelyDeletedObjects to be called once and succeed
$serviceMock->expects($this->once())
->method('syncRemotelyDeletedObjects')
->with(\Jiminny\Enums\CrmObject::ACCOUNT);
// Call the protected method using reflection
$reflection = new \ReflectionClass($serviceMock);
$method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');
$method->setAccessible(true);
// Should not throw any exceptions
$method->invoke($serviceMock, \Jiminny\Enums\CrmObject::ACCOUNT);
$this->assertTrue(true); // Test completed successfully
}
public function testSyncRemotelyDeletedObjectsWithErrorHandlingFailure(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$prospectPhotoPathService,
])
->onlyMethods(['syncRemotelyDeletedObjects'])
->getMock();
// Mock team
$team = $this->createMock(Team::class);
$team->method('getUuid')->willReturn('team-uuid-456');
$serviceMock->team = $team;
// Mock logger to verify warning is logged
$logger = $this->createMock(\Psr\Log\LoggerInterface::class);
// Use reflection to set the protected logger property
$reflection = new \ReflectionClass($serviceMock);
$loggerProperty = $reflection->getProperty('logger');
$loggerProperty->setAccessible(true);
$loggerProperty->setValue($serviceMock, $logger);
$exception = new \Exception('Sync failed due to API error');
// Expect syncRemotelyDeletedObjects to throw an exception
$serviceMock->expects($this->once())
->method('syncRemotelyDeletedObjects')
->with(\Jiminny\Enums\CrmObject::CONTACT)
->willThrowException($exception);
// Expect warning to be logged with correct message and parameters
$logger->expects($this->once())
->method('warning')
->with(
'[Salesforce] Remotely deleted objects sync failed',
[
'objectType' => 'contact',
'teamId' => 'team-uuid-456',
'reason' => 'Sync failed due to API error',
]
);
// Call the protected method using reflection
$reflection = new \ReflectionClass($serviceMock);
$method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');
$method->setAccessible(true);
// Should not re-throw the exception, just log it
$method->invoke($serviceMock, \Jiminny\Enums\CrmObject::CONTACT);
$this->assertTrue(true); // Test completed successfully
}
public function testSyncRemotelyDeletedObjectsWithErrorHandlingWithLogParams(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$prospectPhotoPathService,
])
->onlyMethods(['syncRemotelyDeletedObjects'])
->getMock();
// Mock team
$team = $this->createMock(Team::class);
$team->method('getUuid')->willReturn('team-uuid-789');
$serviceMock->team = $team;
// Mock logger to verify warning is logged
$logger = $this->createMock(\Psr\Log\LoggerInterface::class);
// Use reflection to set the protected logger property
$loggerReflection = new \ReflectionClass($serviceMock);
$loggerProperty = $loggerReflection->getProperty('logger');
$loggerProperty->setAccessible(true);
$loggerProperty->setValue($serviceMock, $logger);
$exception = new \Exception('Network timeout');
// Expect syncRemotelyDeletedObjects to throw an exception
$serviceMock->expects($this->once())
->method('syncRemotelyDeletedObjects')
->with(\Jiminny\Enums\CrmObject::OPPORTUNITY)
->willThrowException($exception);
// Additional log parameters
$logParams = [
'syncType' => 'full',
'batchSize' => 100,
];
// Expect warning to be logged with merged parameters
$logger->expects($this->once())
->method('warning')
->with(
'[Salesforce] Remotely deleted objects sync failed',
[
'objectType' => 'opportunity',
'teamId' => 'team-uuid-789',
'reason' => 'Network timeout',
'syncType' => 'full',
'batchSize' => 100,
]
);
// Call the protected method using reflection
$reflection = new \ReflectionClass($serviceMock);
$method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');
$method->setAccessible(true);...
|
70623
|
NULL
|
NULL
|
NULL
|
|
70630
|
2537
|
8
|
2026-05-22T10:25:37.355749+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779445537355_m2.jpg...
|
PhpStorm
|
faVsco.js – ServiceTest.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20963-fix-import-on-de Project: faVsco.js, menu
JY-20963-fix-import-on-deleted-entity, menu
Start Listening for PHP Debug Connections
ServiceTest
Rerun 'PHPUnit: ServiceTest'
Debug 'ServiceTest'
Stop 'ServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Built-in Preview
Chrome
Firefox
Safari
Sync Changes
Hide This Notification
Code changed:
Hide
4
32
168
1
28
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Tests\Unit\Services\Crm\Salesforce;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Events\Dispatcher;
use Illuminate\Support\Facades\Cache;
use Jiminny\Component\Country\CountriesMap;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Enums\CrmObject;
use Jiminny\Exceptions\InvalidArgumentException;
use Jiminny\Exceptions\NoResultsException;
use Jiminny\Integrations\PlaybookResolver;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Playbook;
use Jiminny\Models\Team;
use Jiminny\Models\TeamSettings;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\ContactRoleRepository;
use Jiminny\Repositories\Crm\FieldDataRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\Salesforce\Client;
use Jiminny\Services\Crm\Salesforce\PayloadBuilder;
use Jiminny\Services\Crm\Salesforce\QueryBuilder;
use Jiminny\Services\Crm\Salesforce\QueryHandler;
use Jiminny\Services\Crm\Salesforce\QueryIterator;
use Jiminny\Services\Crm\Salesforce\QueryResults;
use Jiminny\Services\Crm\Salesforce\Service;
use PHPUnit\Framework\MockObject\MockObject;
use Tests\TestCase;
use Tests\Unit\Traits\TestPrivateMethod;
class ServiceTest extends TestCase
{
use TestPrivateMethod;
public function testFetchAndAssociateRelatedActivity(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$payloadBuilder->method('addCustomLogicFieldsPayload')
->willReturnCallback(function ($activity, $payload) {
return $payload;
});
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods(['fetchRelatedActivity', 'getPlaybook', 'getPlaybookCategory', 'updateRecord'])
->getMock();
$serviceMock->expects($this->once())
->method('fetchRelatedActivity')
->willReturn([
'Id' => 'testId',
'Type' => null,
'OwnerId' => 'testerUser',
'Description' => 'Test description',
]);
$user = $this->createMock(User::class);
$team = $this->createMock(Team::class);
$user->method('getAttribute')->with('team')->willReturn($team);
$playbook = $this->createMock(Playbook::class);
$playbook->method('getActivityField')->willReturn(null);
$playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_EVENT);
$serviceMock->expects($this->once())
->method('getPlaybook')
->with($user)
->willReturn($playbook);
$serviceMock->expects($this->never())
->method('getPlaybookCategory');
$serviceMock->expects($this->never())
->method('updateRecord');
$fieldDataRepository = $this->createMock(FieldDataRepository::class);
$fieldDataRepository->method('getActivityFieldData')->willReturn(collect([]));
app()->instance(FieldDataRepository::class, $fieldDataRepository);
$config = $this->createMock(Configuration::class);
$profilesRelation = $this->getMockBuilder(\Illuminate\Database\Eloquent\Relations\HasMany::class)
->disableOriginalConstructor()
->onlyMethods(['get'])
->addMethods(['where'])
->getMock();
$profilesRelation->method('where')->willReturnSelf();
$profilesRelation->method('get')->willReturn(collect([]));
$config->method('profiles')->willReturn($profilesRelation);
$serviceMock->config = $config;
$serviceMock->profile = null;
$actualStartTime = \Carbon\Carbon::now();
$activity = $this->getMockBuilder(Activity::class)
->disableOriginalConstructor()
->onlyMethods(['update', 'hasProspect'])
->getMock();
$activity->method('update')->willReturn(true);
$activity->method('hasProspect')->willReturn(true);
$activity->type = Activity::TYPE_CONFERENCE;
$activity->provider = Activity::PROVIDER_TWILIO;
$activity->lead_id = 1;
$activity->user_id = 0;
$activity->id_string = 'test-activity-id';
$activity->user = $user;
$activity->actual_start_time = $actualStartTime;
$activity->uuid = 'c53d8320-f556-4cee-a2f8-5f232f454ca4';
app()->bind(PlaybookResolver::class, function () use ($user) {
$playbook = $this->createMock(Playbook::class);
$playbookResolver = $this->createMock(PlaybookResolver::class);
$playbookResolver->expects($this->once())
->method('resolvePlaybookByUser')
->with($user)
->willReturn($playbook);
return $playbookResolver;
});
$data = $serviceMock->fetchAndAssociateRelatedActivity($activity);
$this->assertInstanceOf(Activity::class, $data);
$this->assertEquals(Activity::TYPE_CONFERENCE, $data->getType());
$this->assertEquals($actualStartTime->getTimestamp(), $data->getActualStartTime()->getTimestamp());
}
public function testFetchAndAssociateRelatedActivitySkipsForTaskBasedPlaybook(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods(['fetchRelatedActivity', 'getPlaybook'])
->getMock();
$user = $this->createMock(User::class);
$playbook = $this->createMock(Playbook::class);
$playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);
$playbook->method('getId')->willReturn(123);
$serviceMock->expects($this->once())
->method('getPlaybook')
->with($user)
->willReturn($playbook);
$serviceMock->expects($this->never())
->method('fetchRelatedActivity');
$activity = $this->getMockBuilder(Activity::class)
->disableOriginalConstructor()
->onlyMethods(['hasProspect', 'getUuid'])
->getMock();
$activity->method('hasProspect')->willReturn(true);
$activity->method('getUuid')->willReturn('c53d8320-f556-4cee-a2f8-5f232f454ca4');
$activity->type = Activity::TYPE_CONFERENCE;
$activity->actual_start_time = \Carbon\Carbon::now();
$activity->user = $user;
$result = $serviceMock->fetchAndAssociateRelatedActivity($activity);
$this->assertNull($result);
}
public function testFetchAndAssociateRelatedActivityReturnsNullForNonConference(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$serviceMock = new Service(
client: $client,
payloadBuilder: $payloadBuilder,
eventDispatcher: $eventDispatcher,
countriesMap: $countriesMap,
prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)
);
$activity = $this->getMockBuilder(Activity::class)
->disableOriginalConstructor()
->getMock();
$activity->type = Activity::TYPE_SOFTPHONE;
$result = $serviceMock->fetchAndAssociateRelatedActivity($activity);
$this->assertNull($result);
}
public function testFetchAndAssociateRelatedActivityReturnsNullWhenNoStartTime(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$serviceMock = new Service(
client: $client,
payloadBuilder: $payloadBuilder,
eventDispatcher: $eventDispatcher,
countriesMap: $countriesMap,
prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)
);
$activity = $this->getMockBuilder(Activity::class)
->disableOriginalConstructor()
->getMock();
$activity->type = Activity::TYPE_CONFERENCE;
$activity->actual_start_time = null;
$activity->scheduled_start_time = null;
$result = $serviceMock->fetchAndAssociateRelatedActivity($activity);
$this->assertNull($result);
}
public function testFetchAndAssociateRelatedActivityReturnsNullWhenNoProspect(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods(['getPlaybook'])
->getMock();
$serviceMock->expects($this->never())
->method('getPlaybook');
$activity = $this->getMockBuilder(Activity::class)
->disableOriginalConstructor()
->onlyMethods(['hasProspect', 'getUuid'])
->getMock();
$activity->method('hasProspect')->willReturn(false);
$activity->method('getUuid')->willReturn('c53d8320-f556-4cee-a2f8-5f232f454ca4');
$activity->type = Activity::TYPE_CONFERENCE;
$activity->actual_start_time = \Carbon\Carbon::now();
$result = $serviceMock->fetchAndAssociateRelatedActivity($activity);
$this->assertNull($result);
}
public function testMatchExactlyByEmail(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods([])
->getMock();
$profile = new Profile();
$profile->setAttribute('id', bin2hex(random_bytes(8)));
$serviceMock->profile = $profile;
$team = $this->createMock(Team::class);
$serviceMock->team = $team;
$data = $serviceMock->matchExactlyByEmail(bin2hex(random_bytes(8)) . '[EMAIL]');
$this->assertEquals(null, $data);
}
public function testMatchDomainFromEmail(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$queryIterator = $this->createMock(QueryIterator::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$config = $this->createMock(Configuration::class);
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('search')
->willReturn($queryIterator);
return $handler;
});
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods(['convertCrmData'])
->getMock();
$profile = new Profile();
$profile->account_fields = 'Field1, Field2, Field3';
$serviceMock->profile = $profile;
$serviceMock->expects($this->once())
->method('convertCrmData')
->willReturn(['test']);
$this->app->bind(QueryBuilder::class, function () {
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->expects($this->once())
->method('buildMatchByDomainQuery')
->with('[EMAIL]')
->willReturn('FIND {[EMAIL]} IN ALL FIELDS RETURNING Account(Id)');
return $queryBuilder;
});
$team = $this->createMock(Team::class);
$serviceMock->team = $team;
$serviceMock->config = $config;
$data = $serviceMock->matchByDomain('[EMAIL]');
$this->assertEquals(['test'], $data);
}
public function testBuildTaskSearchFields(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$service = new Service(
client: $client,
payloadBuilder: $payloadBuilder,
eventDispatcher: $eventDispatcher,
countriesMap: $countriesMap,
prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)
);
$fields = $service->buildTaskSearchFields();
$expectedFields = ['Id', 'WhoId', 'WhatId', 'AccountId'];
$this->assertEquals($expectedFields, $fields);
}
public function testMapCrmObjects(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$service = new Service(
client: $client,
payloadBuilder: $payloadBuilder,
eventDispatcher: $eventDispatcher,
countriesMap: $countriesMap,
prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class),
);
$sampleTask = [
'WhoId' => '003sampleWhoId',
'AccountId' => 'sampleAccountId',
'WhatId' => 'sampleWhatId',
];
$activityData = $service->mapCrmObjects($sampleTask);
$expectedActivityData = [
'contact' => '003sampleWhoId',
'account' => 'sampleAccountId',
'opportunity' => 'sampleWhatId',
];
$this->assertEquals($expectedActivityData, $activityData);
}
public function testGetInstalledAppVersion(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$queryIterator = $this->createMock(QueryIterator::class);
$queryIterator->expects($this->any())
->method('current')->willReturn([
'SubscriberPackageVersion' => [
'MajorVersion' => '1',
'MinorVersion' => '0',
'PatchVersion' => '1',
'BuildNumber' => '0',
],
]);
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('metadata')
->willReturn($queryIterator);
return $handler;
});
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods(array_diff(get_class_methods(Service::class), ['getInstalledAppVersion']))
->getMock();
$version = $serviceMock->getInstalledAppVersion();
$this->assertEquals('1010', $version);
}
public function testSyncProfiles(): void
{
$userToSearch = null;
$team = $this->createMock(Team::class);
$config = $this->createMockedConfiguration();
$config->expects($this->once())
->method('getId')
->willReturn(1);
$salesforceUser = [
'Email' => '[EMAIL]',
'UserPreferencesLightningExperiencePreferred' => true,
'CallCenterId' => '123',
'Id' => '456',
'ProfileId' => '789',
];
app()->bind(QueryBuilder::class, function () use ($userToSearch) {
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->expects($this->once())
->method('buildGetUsersQuery')
->with($userToSearch)
->willReturn('SELECT * FROM Users');
return $queryBuilder;
});
$queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('query')
->with('SELECT * FROM Users')
->willReturn($queryIterator);
return $handler;
});
$user = $this->createMock(User::class);
$user->expects($this->once())
->method('getId')
->willReturn(123);
$this->mockTeamRepository($team, $salesforceUser, $user);
$profileRepository = $this->createMock(ProfileRepository::class);
$profileRepository->expects($this->once())
->method('updateOrCreateProfile')
->with(
$user,
[
'crm_configuration_id' => 1,
'crm_provider_id' => '456',
],
[
'user_id' => 123,
'edition' => Profile::EDITION_LIGHTNING,
'has_external_cti' => true,
'crm_profile_id' => '789',
]
)
->willReturn(new Profile());
$this->app->instance(ProfileRepository::class, $profileRepository);
$serviceMock = $this->getServiceMock();
$serviceMock->team = $team;
$serviceMock->config = $config;
$result = $serviceMock->syncProfiles($userToSearch);
$this->assertNull($result);
}
public function testSyncProfilesEmailIsNull(): void
{
$userToSearch = $this->createMock(User::class);
$salesforceUser = [
'Email' => null,
];
app()->bind(QueryBuilder::class, function () {
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->expects($this->once())
->method('buildGetUsersQuery')
->with(null)
->willReturn('SELECT * FROM Users');
return $queryBuilder;
});
$queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->exactly(2))
->method('query')
->willReturn($queryIterator);
return $handler;
});
$team = $this->createMock(Team::class);
$user = $this->createMock(User::class);
$this->mockTeamRepository($team, $salesforceUser, $user, false);
$config = $this->createMock(Configuration::class);
$serviceMock = $this->getServiceMock();
$serviceMock->team = $team;
$serviceMock->config = $config;
$profile = $serviceMock->syncProfiles(null);
$this->assertNull($profile);
}
public function testSyncProfilesUserToSearchMatchesCurrentUser(): void
{
$userToSearch = $this->createMock(User::class);
$userToSearch->expects($this->once())
->method('getId')
->willReturn(123);
$team = $this->createMock(Team::class);
$config = $this->createMock(Configuration::class);
$config->expects($this->once())
->method('getId')
->willReturn(1);
$salesforceUser = [
'Email' => '[EMAIL]',
'UserPreferencesLightningExperiencePreferred' => true,
'CallCenterId' => '123',
'Id' => '456',
'ProfileId' => '789',
];
app()->bind(QueryBuilder::class, function () use ($userToSearch) {
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->expects($this->once())
->method('buildGetUsersQuery')
->with($userToSearch)
->willReturn('SELECT * FROM Users');
return $queryBuilder;
});
$queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('query')
->with('SELECT * FROM Users')
->willReturn($queryIterator);
return $handler;
});
$user = $this->createMock(User::class);
$user->expects($this->exactly(2))
->method('getId')
->willReturn(123);
$this->mockTeamRepository($team, $salesforceUser, $user);
$profileRepository = $this->createMock(ProfileRepository::class);
$profileRepository->expects($this->once())
->method('updateOrCreateProfile')
->with(
$user,
[
'crm_configuration_id' => 1,
'crm_provider_id' => '456',
],
[
'user_id' => 123,
'edition' => Profile::EDITION_LIGHTNING,
'has_external_cti' => true,
'crm_profile_id' => '789',
]
)
->willReturn(new Profile());
$this->app->instance(ProfileRepository::class, $profileRepository);
$serviceMock = $this->getServiceMock();
$serviceMock->team = $team;
$serviceMock->config = $config;
$profile = $serviceMock->syncProfiles($userToSearch);
$this->assertInstanceOf(Profile::class, $profile);
}
public function testSyncProfilesWithCustomValidation(): void
{
$userToSearch = null;
$team = $this->createMock(Team::class);
$config = $this->createMockedConfiguration();
$config->expects($this->atLeastOnce()) // Changed from once() to atLeastOnce()
->method('getId')
->willReturn(1);
$salesforceUser = [
'Email' => '[EMAIL]',
'UserPreferencesLightningExperiencePreferred' => true,
'CallCenterId' => '123',
'Id' => '456',
'ProfileId' => '789',
'CustomField' => 'CustomValue',
];
$customRules = [
['field' => 'CustomField', 'value' => 'CustomValue'],
];
$this->mockQueryBuilderAndHandler($userToSearch, [$salesforceUser]);
$user = $this->createMock(User::class);
$user->expects($this->once())
->method('getId')
->willReturn(123);
$this->mockTeamRepository($team, $salesforceUser, $user, true, $customRules);
$this->mockProfileRepository($user);
$serviceMock = $this->getServiceMock();
$serviceMock->team = $team;
$serviceMock->config = $config;
$profile = $serviceMock->syncProfiles($userToSearch);
$this->assertNull($profile);
}
public function testSyncProfilesWithCustomValidationFailing(): void
{
$userToSearch = null;
$team = $this->createMock(Team::class);
$config = $this->createMockedConfiguration();
$salesforceUser = [
'Email' => '[EMAIL]',
'UserPreferencesLightningExperiencePreferred' => true,
'CallCenterId' => '123',
'Id' => '456',
'ProfileId' => '789',
'CustomField' => 'WrongValue',
];
$customRules = [
['field' => 'CustomField', 'value' => 'CustomValue'],
];
$this->mockQueryBuilderAndHandler($userToSearch, [$salesforceUser]);
$teamRepository = $this->getMockForAbstractClass(TeamRepository::class, [], '', false, true, true, ['findActiveTeamMemberByEmail', 'getTeamSetting']);
$teamSettings = $this->createMock(TeamSettings::class);
$teamSettings->method('getValueType')
->willReturn('array');
$teamSettings->method('getValue')
->willReturn(json_encode($customRules));
$teamRepository->expects($this->once())
->method('getTeamSetting')
->with($team, 'custom_profile_validation')
->willReturn($teamSettings);
app()->bind(TeamRepository::class, function () use ($teamRepository) {
return $teamRepository;
});
$profileRepository = $this->createMock(ProfileRepository::class);
$profileRepository->expects($this->never())
->method('updateOrCreateProfile');
$this->app->instance(ProfileRepository::class, $profileRepository);
$serviceMock = $this->getServiceMock();
$serviceMock->team = $team;
$serviceMock->config = $config;
$result = $serviceMock->syncProfiles($userToSearch);
$this->assertNull($result);
}
public function testGetContactRolesFromCrm(): void
{
$contactRoles = [
[
'Id' => '1',
'ContactId' => 'Contact1',
'OpportunityId' => 'Opportunity1',
'Opportunity' => ['OwnerId' => 'Owner1'],
'IsPrimary' => true,
'Role' => 'Decision Maker',
],
];
$expectedResponse = [
[
'id' => '1',
'contactId' => 'Contact1',
'opportunityId' => 'Opportunity1',
'ownerId' => 'Owner1',
'isPrimary' => true,
'role' => 'Decision Maker',
],
];
$this->bindQueryIterator($contactRoles);
$serviceMock = $this->getServiceMock();
$data = $serviceMock->getContactRolesFromCrm(now()->subDay());
$this->assertEquals($expectedResponse, $data);
}
public function testGetContactRolesFromCrmNoResult(): void
{
$this->bindQueryIterator([]);
$serviceMock = $this->getServiceMock();
$data = $serviceMock->getContactRolesFromCrm(now()->subDay());
$this->assertEquals([], $data);
}
public function testSyncContactRoles(): void
{
$contactRoles = [
[
'id' => '1',
'contactId' => 'Contact1',
'opportunityId' => 'Opportunity1',
'ownerId' => 'Owner1',
'isPrimary' => true,
'role' => 'Decision Maker',
],
];
app()->bind(ContactRoleRepository::class, function () {
$contactRoleRepository = $this->createMock(ContactRoleRepository::class);
$contactRoleRepository->expects($this->once())
->method('saveContactRoles');
return $contactRoleRepository;
});
$serviceMock = $this->getServiceMock([
'getContactRolesFromCrm',
'syncRemotelyDeletedContactRoles',
'syncContact',
'syncOpportunity',
]);
$config = $this->createMock(Configuration::class);
$hasMany = $this->createMock(HasManyExtended::class);
$hasMany->expects($this->exactly(2))
->method('where')
->willReturn($hasMany);
$hasMany->expects($this->exactly(2))
->method('first')
->willReturn(
$this->createMock(Contact::class),
$this->createMock(Opportunity::class)
);
$config->expects($this->once())
->method('contacts')
->willReturn($hasMany);
$config->expects($this->once())
->method('opportunities')
->willReturn($hasMany);
$serviceMock->config = $config;
$serviceMock->expects($this->once())
->method('getContactRolesFromCrm')
->willReturn($contactRoles);
$serviceMock->expects($this->once())
->method('syncRemotelyDeletedContactRoles');
$data = $serviceMock->syncContactRoles(now()->subDay());
$this->assertEquals(1, $data);
}
public function testSyncRemotelyDeletedContactRoles(): void
{
$contactRoles = [
[
'id' => '1',
'crm_provider_id' => '1',
],
];
app()->bind(QueryHandler::class, function () use ($contactRoles) {
$queryResults = new QueryResults($contactRoles, 1, true, null);
$handler = $this->createMock(QueryHandler::class);
$handler->method('queryDeleted')
->willReturn($queryResults);
return $handler;
});
app()->bind(ContactRoleRepository::class, function () {
$contactRoleRepository = $this->createMock(ContactRoleRepository::class);
$contactRoleRepository->expects($this->once())
->method('deleteContactRoles');
return $contactRoleRepository;
});
$serviceMock = $this->getServiceMock();
$serviceMock->team = $this->createMock(Team::class);
$data = $this->invokePrivateMethod('syncRemotelyDeletedContactRoles', $serviceMock, []);
$this->assertTrue($data);
}
private function bindQueryIterator(array $queryResult): void
{
/** @var Client $client */
$client = $this->createMock(Client::class);
$queryIterator = new QueryIterator(
$client,
new QueryResults($queryResult, 1, true, null)
);
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('query')
->willReturn($queryIterator);
return $handler;
});
}
public static function getOpportunitySortOrderDataProvider(): array
{
return [
'all open recently updated' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED, ['LastModifiedDate DESC', true]],
'all open recently created' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED, ['CreatedDate DESC', true]],
'all open oldest created' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED, ['CreatedDate ASC', true]],
'all recently updated' => [Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED, ['LastModifiedDate DESC', false]],
'default' => ['unknown', ['LastModifiedDate DESC', true]],
];
}
private function createMockedConfiguration(): Configuration
{
$config = $this->createMock(Configuration::class);
$profilesRelation = $this->getMockBuilder(\Illuminate\Database\Eloquent\Relations\HasMany::class)
->disableOriginalConstructor()
->onlyMethods(['get'])
->addMethods(['where', 'first'])
->getMock();
$profilesRelation->method('where')->willReturnSelf();
$profilesRelation->method('get')->willReturn(collect([]));
$profilesRelation->method('first')->willReturn(null);
$config->method('profiles')->willReturn($profilesRelation);
return $config;
}
private function getServiceMock(array $onlyMethods = []): MockObject&Service
{
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$this->createMock(Client::class),
$this->createMock(PayloadBuilder::class),
$this->createMock(Dispatcher::class),
$this->createMock(CountriesMap::class),
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods($onlyMethods)
->getMock();
$serviceMock->profile = $this->createMock(Profile::class);
return $serviceMock;
}
private function mockQueryBuilderAndHandler($userToSearch, $salesforceUsers): void
{
app()->bind(QueryBuilder::class, function () use ($userToSearch) {
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->expects($this->once())
->method('buildGetUsersQuery')
->with($userToSearch)
->willReturn('SELECT * FROM Users');
return $queryBuilder;
});
$queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults($salesforceUsers, count($salesforceUsers), true, null));
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('query')
->willReturn($queryIterator);
return $handler;
});
}
private function mockTeamRepository(
Team $team,
array $salesforceUser,
?User $user = null,
bool $userSearch = true,
array $customRules = []
): void {
$teamRepository = $this->getMockForAbstractClass(TeamRepository::class, [], '', false, true, true, ['findActiveTeamMemberByEmail', 'getTeamSetting']);
if ($userSearch) {
$teamRepository->expects($this->once())
->method('findActiveTeamMemberByEmail')
->with($team, $salesforceUser['Email'])
->willReturn($user);
}
$teamSettings = $this->createMock(TeamSettings::class);
$teamSettings->method('getValueType')
->willReturn('array');
$teamSettings->method('getValue')
->willReturn(json_encode($customRules));
$teamRepository->expects($this->once())
->method('getTeamSetting')
->with($team, 'custom_profile_validation')
->willReturn($teamSettings);
app()->bind(TeamRepository::class, function () use ($teamRepository) {
return $teamRepository;
});
}
private function mockProfileRepository(User $user): void
{
$profileRepository = $this->createMock(ProfileRepository::class);
$profileRepository->expects($this->once())
->method('updateOrCreateProfile')
->with(
$user,
[
'crm_configuration_id' => 1,
'crm_provider_id' => '456',
],
[
'user_id' => 123,
'edition' => Profile::EDITION_LIGHTNING,
'has_external_cti' => true,
'crm_profile_id' => '789',
]
)
->willReturn(new Profile());
$this->app->instance(ProfileRepository::class, $profileRepository);
}
public function testBuildEnhancedNoteDecodesHtmlEntities(): void
{
$service = $this->getServiceMock(['createRecord']);
$profile = new Profile();
$profile->setAttribute('crm_provider_id', 'owner-123');
$service->profile = $profile;
$service->expects($this->exactly(2))
->method('createRecord')
->willReturnOnConsecutiveCalls('note-id-123', 'link-id-456');
$bodyWithEntities = 'Welch's current challenges and Facebook's Club';
$result = $this->invokePrivateMethod('buildEnhancedNote', $service, [
'Test Title',
$bodyWithEntities,
'object-id-789',
]);
$this->assertEquals('note-id-123', $result);
}
public function testBuildEnhancedNoteSanitizesWithoutQuotes(): void
{
$service = $this->getServiceMock(['createRecord']);
$profile = new Profile();
$profile->setAttribute('crm_provider_id', 'owner-456');
$service->profile = $profile;
$service->expects($this->exactly(2))
->method('createRecord')
->willReturnCallback(function ($type, $data) {
if ($type === 'ContentNote') {
$decoded = base64_decode($data['Content']);
$this->assertStringContainsString("Welch's", $decoded);
$this->assertStringNotContainsString(''', $decoded);
$this->assertStringNotContainsString('&#039;', $decoded);
$this->assertStringContainsString('<script>', $decoded);
return 'note-id-456';
}
return 'link-id-789';
});
$bodyWithMixedContent = "Welch's and <script>alert('xss')</script>";
$result = $this->invokePrivateMethod('buildEnhancedNote', $service, [
'Test Title',
$bodyWithMixedContent,
'object-id-123',
]);
$this->assertEquals('note-id-456', $result);
}
public function testBuildEnhancedNoteConvertsLineBreaks(): void
{
$service = $this->getServiceMock(['createRecord']);
$profile = new Profile();
$profile->setAttribute('crm_provider_id', 'owner-789');
$service->profile = $profile;
$service->expects($this->exactly(2))
->method('createRecord')
->willReturnCallback(function ($type, $data) {
if ($type === 'ContentNote') {
$decoded = base64_decode($data['Content']);
$this->assertStringContainsString('<br>', $decoded);
$this->assertStringNotContainsString('<br />', $decoded);
return 'note-id-789';
}
return 'link-id-012';
});
$bodyWithLineBreaks = "Line 1\nLine 2\nLine 3";
$result = $this->invokePrivateMethod('buildEnhancedNote', $service, [
'Test Title',
$bodyWithLineBreaks,
'object-id-456',
]);
$this->assertEquals('note-id-789', $result);
}
public function testBuildEnhancedNoteHandlesComplexScenario(): void
{
$service = $this->getServiceMock(['createRecord']);
$profile = new Profile();
$profile->setAttribute('crm_provider_id', 'owner-complex');
$service->profile = $profile;
$service->expects($this->exactly(2))
->method('createRecord')
->willReturnCallback(function ($type, $data) {
if ($type === 'ContentNote') {
$decoded = base64_decode($data['Content']);
$this->assertStringContainsString("Welch's", $decoded);
$this->assertStringContainsString("Facebook's Club", $decoded);
$this->assertStringContainsString("Arctics'", $decoded);
$this->assertStringNotContainsString(''', $decoded);
$this->assertStringNotContainsString('&#039;', $decoded);
$this->assertStringContainsString('<br>', $decoded);
$this->assertStringContainsString('<', $decoded);
$this->assertStringContainsString('>', $decoded);
return 'note-complex';
}
return 'link-complex';
});
$complexBody = "Summary:\n---------\nThe call focused on understanding Welch's current challenges and exploring how Arctics' Revenue Growth Management solutions could support their strategic goals.\n\n• John SMith discussed his role as a category advisor for Google and Facebook's Club, emphasizing the importance of market research and advising on product assortment.\n• Madona introduced Arctics' Virtual Shoppers AI, which simulates consumer <behavior> to optimize pricing and promotional strategies.";
$result = $this->invokePrivateMethod('buildEnhancedNote', $service, [
'Jiminny Transcription Summary',
$complexBody,
'task-id-001',
]);
$this->assertEquals('note-complex', $result);
}
public function testSyncRemotelyDeletedObjectsWithErrorHandlingSuccess(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$prospectPhotoPathService,
])
->onlyMethods(['syncRemotelyDeletedObjects'])
->getMock();
// Mock team
$team = $this->createMock(Team::class);
$team->method('getUuid')->willReturn('team-uuid-123');
$serviceMock->team = $team;
// Expect syncRemotelyDeletedObjects to be called once and succeed
$serviceMock->expects($this->once())
->method('syncRemotelyDeletedObjects')
->with(\Jiminny\Enums\CrmObject::ACCOUNT);
// Call the protected method using reflection
$reflection = new \ReflectionClass($serviceMock);
$method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');
$method->setAccessible(true);
// Should not throw any exceptions
$method->invoke($serviceMock, \Jiminny\Enums\CrmObject::ACCOUNT);
$this->assertTrue(true); // Test completed successfully
}
public function testSyncRemotelyDeletedObjectsWithErrorHandlingFailure(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$prospectPhotoPathService,
])
->onlyMethods(['syncRemotelyDeletedObjects'])
->getMock();
// Mock team
$team = $this->createMock(Team::class);
$team->method('getUuid')->willReturn('team-uuid-456');
$serviceMock->team = $team;
// Mock logger to verify warning is logged
$logger = $this->createMock(\Psr\Log\LoggerInterface::class);
// Use reflection to set the protected logger property
$reflection = new \ReflectionClass($serviceMock);
$loggerProperty = $reflection->getProperty('logger');
$loggerProperty->setAccessible(true);
$loggerProperty->setValue($serviceMock, $logger);
$exception = new \Exception('Sync failed due to API error');
// Expect syncRemotelyDeletedObjects to throw an exception
$serviceMock->expects($this->once())
->method('syncRemotelyDeletedObjects')
->with(\Jiminny\Enums\CrmObject::CONTACT)
->willThrowException($exception);
// Expect warning to be logged with correct message and parameters
$logger->expects($this->once())
->method('warning')
->with(
'[Salesforce] Remotely deleted objects sync failed',
[
'objectType' => 'contact',
'teamId' => 'team-uuid-456',
'reason' => 'Sync failed due to API error',
]
);
// Call the protected method using reflection
$reflection = new \ReflectionClass($serviceMock);
$method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');
$method->setAccessible(true);
// Should not re-throw the exception, just log it
$method->invoke($serviceMock, \Jiminny\Enums\CrmObject::CONTACT);
$this->assertTrue(true); // Test completed successfully
}
public function testSyncRemotelyDeletedObjectsWithErrorHandlingWithLogParams(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$prospectPhotoPathService,
])
->onlyMethods(['syncRemotelyDeletedObjects'])
->getMock();
// Mock team
$team = $this->createMock(Team::class);
$team->method('getUuid')->willReturn('team-uuid-789');
$serviceMock->team = $team;
// Mock logger to verify warning is logged
$logger = $this->createMock(\Psr\Log\LoggerInterface::class);
// Use reflection to set the protected logger property
$loggerReflection = new \ReflectionClass($serviceMock);
$loggerProperty = $loggerReflection->getProperty('logger');
$loggerProperty->setAccessible(true);
$loggerProperty->setValue($serviceMock, $logger);
$exception = new \Exception('Network timeout');
// Expect syncRemotelyDeletedObjects to throw an exception
$serviceMock->expects($this->once())
->method('syncRemotelyDeletedObjects')
->with(\Jiminny\Enums\CrmObject::OPPORTUNITY)
->willThrowException($exception);
// Additional log parameters
$logParams = [
'syncType' => 'full',
'batchSize' => 100,
];
// Expect warning to be logged with merged parameters
$logger->expects($this->once())
->method('warning')
->with(
'[Salesforce] Remotely deleted objects sync failed',
[
'objectType' => 'opportunity',
'teamId' => 'team-uuid-789',
'reason' => 'Network timeout',
'syncType' => 'full',
'batchSize' => 100,
]
);
// Call the protected method using reflection
$reflection = new \ReflectionClass($serviceMock);
$method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');
...
|
[{"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-20963-fix-import-on-deleted-entity, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.09607713,"height":0.025538707},"on_screen":true,"help_text":"Git Branch: JY-20963-fix-import-on-deleted-entity","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.85638297,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"ServiceTest","depth":6,"bounds":{"left":0.87167555,"top":0.019952115,"width":0.043882977,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Rerun 'PHPUnit: ServiceTest'","depth":6,"bounds":{"left":0.9155585,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'ServiceTest'","depth":6,"bounds":{"left":0.9268617,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Stop 'ServiceTest'","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":"More Actions","depth":6,"bounds":{"left":0.9494681,"top":0.019952115,"width":0.011303191,"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":"Built-in Preview","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":"Chrome","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":"Firefox","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":"Safari","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":"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":"4","depth":4,"bounds":{"left":0.35472074,"top":0.10055866,"width":0.007978723,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"32","depth":4,"bounds":{"left":0.36469415,"top":0.10055866,"width":0.010305851,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"168","depth":4,"bounds":{"left":0.37699467,"top":0.10055866,"width":0.011968086,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"bounds":{"left":0.39095744,"top":0.10055866,"width":0.00731383,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"28","depth":4,"bounds":{"left":0.40026596,"top":0.10055866,"width":0.009973404,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.4119016,"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.4192154,"top":0.09896249,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Services\\Crm\\Salesforce;\n\nuse Illuminate\\Database\\Eloquent\\Relations\\HasMany;\nuse Illuminate\\Events\\Dispatcher;\nuse Illuminate\\Support\\Facades\\Cache;\nuse Jiminny\\Component\\Country\\CountriesMap;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Enums\\CrmObject;\nuse Jiminny\\Exceptions\\InvalidArgumentException;\nuse Jiminny\\Exceptions\\NoResultsException;\nuse Jiminny\\Integrations\\PlaybookResolver;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Crm\\Configuration;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\TeamSettings;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\ContactRoleRepository;\nuse Jiminny\\Repositories\\Crm\\FieldDataRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\Salesforce\\Client;\nuse Jiminny\\Services\\Crm\\Salesforce\\PayloadBuilder;\nuse Jiminny\\Services\\Crm\\Salesforce\\QueryBuilder;\nuse Jiminny\\Services\\Crm\\Salesforce\\QueryHandler;\nuse Jiminny\\Services\\Crm\\Salesforce\\QueryIterator;\nuse Jiminny\\Services\\Crm\\Salesforce\\QueryResults;\nuse Jiminny\\Services\\Crm\\Salesforce\\Service;\nuse PHPUnit\\Framework\\MockObject\\MockObject;\nuse Tests\\TestCase;\nuse Tests\\Unit\\Traits\\TestPrivateMethod;\n\nclass ServiceTest extends TestCase\n{\n use TestPrivateMethod;\n\n public function testFetchAndAssociateRelatedActivity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $payloadBuilder->method('addCustomLogicFieldsPayload')\n ->willReturnCallback(function ($activity, $payload) {\n return $payload;\n });\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods(['fetchRelatedActivity', 'getPlaybook', 'getPlaybookCategory', 'updateRecord'])\n ->getMock();\n\n $serviceMock->expects($this->once())\n ->method('fetchRelatedActivity')\n ->willReturn([\n 'Id' => 'testId',\n 'Type' => null,\n 'OwnerId' => 'testerUser',\n 'Description' => 'Test description',\n ]);\n\n $user = $this->createMock(User::class);\n $team = $this->createMock(Team::class);\n $user->method('getAttribute')->with('team')->willReturn($team);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityField')->willReturn(null);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_EVENT);\n\n $serviceMock->expects($this->once())\n ->method('getPlaybook')\n ->with($user)\n ->willReturn($playbook);\n\n $serviceMock->expects($this->never())\n ->method('getPlaybookCategory');\n\n $serviceMock->expects($this->never())\n ->method('updateRecord');\n\n $fieldDataRepository = $this->createMock(FieldDataRepository::class);\n $fieldDataRepository->method('getActivityFieldData')->willReturn(collect([]));\n app()->instance(FieldDataRepository::class, $fieldDataRepository);\n\n $config = $this->createMock(Configuration::class);\n $profilesRelation = $this->getMockBuilder(\\Illuminate\\Database\\Eloquent\\Relations\\HasMany::class)\n ->disableOriginalConstructor()\n ->onlyMethods(['get'])\n ->addMethods(['where'])\n ->getMock();\n $profilesRelation->method('where')->willReturnSelf();\n $profilesRelation->method('get')->willReturn(collect([]));\n $config->method('profiles')->willReturn($profilesRelation);\n\n $serviceMock->config = $config;\n $serviceMock->profile = null;\n\n $actualStartTime = \\Carbon\\Carbon::now();\n\n $activity = $this->getMockBuilder(Activity::class)\n ->disableOriginalConstructor()\n ->onlyMethods(['update', 'hasProspect'])\n ->getMock();\n\n $activity->method('update')->willReturn(true);\n $activity->method('hasProspect')->willReturn(true);\n\n $activity->type = Activity::TYPE_CONFERENCE;\n $activity->provider = Activity::PROVIDER_TWILIO;\n $activity->lead_id = 1;\n $activity->user_id = 0;\n $activity->id_string = 'test-activity-id';\n $activity->user = $user;\n\n $activity->actual_start_time = $actualStartTime;\n $activity->uuid = 'c53d8320-f556-4cee-a2f8-5f232f454ca4';\n\n app()->bind(PlaybookResolver::class, function () use ($user) {\n $playbook = $this->createMock(Playbook::class);\n $playbookResolver = $this->createMock(PlaybookResolver::class);\n $playbookResolver->expects($this->once())\n ->method('resolvePlaybookByUser')\n ->with($user)\n ->willReturn($playbook);\n\n return $playbookResolver;\n });\n\n $data = $serviceMock->fetchAndAssociateRelatedActivity($activity);\n\n $this->assertInstanceOf(Activity::class, $data);\n $this->assertEquals(Activity::TYPE_CONFERENCE, $data->getType());\n $this->assertEquals($actualStartTime->getTimestamp(), $data->getActualStartTime()->getTimestamp());\n }\n\n public function testFetchAndAssociateRelatedActivitySkipsForTaskBasedPlaybook(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods(['fetchRelatedActivity', 'getPlaybook'])\n ->getMock();\n\n $user = $this->createMock(User::class);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n $playbook->method('getId')->willReturn(123);\n\n $serviceMock->expects($this->once())\n ->method('getPlaybook')\n ->with($user)\n ->willReturn($playbook);\n\n $serviceMock->expects($this->never())\n ->method('fetchRelatedActivity');\n\n $activity = $this->getMockBuilder(Activity::class)\n ->disableOriginalConstructor()\n ->onlyMethods(['hasProspect', 'getUuid'])\n ->getMock();\n\n $activity->method('hasProspect')->willReturn(true);\n $activity->method('getUuid')->willReturn('c53d8320-f556-4cee-a2f8-5f232f454ca4');\n $activity->type = Activity::TYPE_CONFERENCE;\n $activity->actual_start_time = \\Carbon\\Carbon::now();\n $activity->user = $user;\n\n $result = $serviceMock->fetchAndAssociateRelatedActivity($activity);\n\n $this->assertNull($result);\n }\n\n public function testFetchAndAssociateRelatedActivityReturnsNullForNonConference(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $serviceMock = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)\n );\n\n $activity = $this->getMockBuilder(Activity::class)\n ->disableOriginalConstructor()\n ->getMock();\n\n $activity->type = Activity::TYPE_SOFTPHONE;\n\n $result = $serviceMock->fetchAndAssociateRelatedActivity($activity);\n\n $this->assertNull($result);\n }\n\n public function testFetchAndAssociateRelatedActivityReturnsNullWhenNoStartTime(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $serviceMock = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)\n );\n\n\n $activity = $this->getMockBuilder(Activity::class)\n ->disableOriginalConstructor()\n ->getMock();\n\n $activity->type = Activity::TYPE_CONFERENCE;\n $activity->actual_start_time = null;\n $activity->scheduled_start_time = null;\n\n $result = $serviceMock->fetchAndAssociateRelatedActivity($activity);\n\n $this->assertNull($result);\n }\n\n public function testFetchAndAssociateRelatedActivityReturnsNullWhenNoProspect(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods(['getPlaybook'])\n ->getMock();\n\n $serviceMock->expects($this->never())\n ->method('getPlaybook');\n\n $activity = $this->getMockBuilder(Activity::class)\n ->disableOriginalConstructor()\n ->onlyMethods(['hasProspect', 'getUuid'])\n ->getMock();\n\n $activity->method('hasProspect')->willReturn(false);\n $activity->method('getUuid')->willReturn('c53d8320-f556-4cee-a2f8-5f232f454ca4');\n $activity->type = Activity::TYPE_CONFERENCE;\n $activity->actual_start_time = \\Carbon\\Carbon::now();\n\n $result = $serviceMock->fetchAndAssociateRelatedActivity($activity);\n\n $this->assertNull($result);\n }\n\n public function testMatchExactlyByEmail(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods([])\n ->getMock();\n\n $profile = new Profile();\n $profile->setAttribute('id', bin2hex(random_bytes(8)));\n $serviceMock->profile = $profile;\n\n $team = $this->createMock(Team::class);\n $serviceMock->team = $team;\n\n $data = $serviceMock->matchExactlyByEmail(bin2hex(random_bytes(8)) . 'test_email@testserver.com');\n\n $this->assertEquals(null, $data);\n }\n\n public function testMatchDomainFromEmail(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $queryIterator = $this->createMock(QueryIterator::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $config = $this->createMock(Configuration::class);\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('search')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods(['convertCrmData'])\n ->getMock();\n\n $profile = new Profile();\n $profile->account_fields = 'Field1, Field2, Field3';\n $serviceMock->profile = $profile;\n\n $serviceMock->expects($this->once())\n ->method('convertCrmData')\n ->willReturn(['test']);\n\n $this->app->bind(QueryBuilder::class, function () {\n $queryBuilder = $this->createMock(QueryBuilder::class);\n $queryBuilder->expects($this->once())\n ->method('buildMatchByDomainQuery')\n ->with('test_email@testserver.com')\n ->willReturn('FIND {test_email@testserver.com} IN ALL FIELDS RETURNING Account(Id)');\n\n return $queryBuilder;\n });\n\n $team = $this->createMock(Team::class);\n\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n\n $data = $serviceMock->matchByDomain('test_email@testserver.com');\n\n $this->assertEquals(['test'], $data);\n }\n\n public function testBuildTaskSearchFields(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)\n );\n\n $fields = $service->buildTaskSearchFields();\n\n $expectedFields = ['Id', 'WhoId', 'WhatId', 'AccountId'];\n\n $this->assertEquals($expectedFields, $fields);\n }\n\n public function testMapCrmObjects(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class),\n );\n\n $sampleTask = [\n 'WhoId' => '003sampleWhoId',\n 'AccountId' => 'sampleAccountId',\n 'WhatId' => 'sampleWhatId',\n ];\n\n $activityData = $service->mapCrmObjects($sampleTask);\n\n $expectedActivityData = [\n 'contact' => '003sampleWhoId',\n 'account' => 'sampleAccountId',\n 'opportunity' => 'sampleWhatId',\n ];\n\n $this->assertEquals($expectedActivityData, $activityData);\n }\n\n public function testGetInstalledAppVersion(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n $queryIterator = $this->createMock(QueryIterator::class);\n $queryIterator->expects($this->any())\n ->method('current')->willReturn([\n 'SubscriberPackageVersion' => [\n 'MajorVersion' => '1',\n 'MinorVersion' => '0',\n 'PatchVersion' => '1',\n 'BuildNumber' => '0',\n ],\n ]);\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('metadata')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods(array_diff(get_class_methods(Service::class), ['getInstalledAppVersion']))\n ->getMock();\n\n $version = $serviceMock->getInstalledAppVersion();\n\n $this->assertEquals('1010', $version);\n }\n\n public function testSyncProfiles(): void\n {\n $userToSearch = null;\n\n $team = $this->createMock(Team::class);\n $config = $this->createMockedConfiguration();\n $config->expects($this->once())\n ->method('getId')\n ->willReturn(1);\n\n $salesforceUser = [\n 'Email' => 'test@example.com',\n 'UserPreferencesLightningExperiencePreferred' => true,\n 'CallCenterId' => '123',\n 'Id' => '456',\n 'ProfileId' => '789',\n ];\n\n app()->bind(QueryBuilder::class, function () use ($userToSearch) {\n $queryBuilder = $this->createMock(QueryBuilder::class);\n $queryBuilder->expects($this->once())\n ->method('buildGetUsersQuery')\n ->with($userToSearch)\n ->willReturn('SELECT * FROM Users');\n\n return $queryBuilder;\n });\n\n $queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('query')\n ->with('SELECT * FROM Users')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n\n $user = $this->createMock(User::class);\n $user->expects($this->once())\n ->method('getId')\n ->willReturn(123);\n\n $this->mockTeamRepository($team, $salesforceUser, $user);\n\n $profileRepository = $this->createMock(ProfileRepository::class);\n $profileRepository->expects($this->once())\n ->method('updateOrCreateProfile')\n ->with(\n $user,\n [\n 'crm_configuration_id' => 1,\n 'crm_provider_id' => '456',\n ],\n [\n 'user_id' => 123,\n 'edition' => Profile::EDITION_LIGHTNING,\n 'has_external_cti' => true,\n 'crm_profile_id' => '789',\n ]\n )\n ->willReturn(new Profile());\n\n $this->app->instance(ProfileRepository::class, $profileRepository);\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n $result = $serviceMock->syncProfiles($userToSearch);\n\n $this->assertNull($result);\n }\n\n public function testSyncProfilesEmailIsNull(): void\n {\n $userToSearch = $this->createMock(User::class);\n\n $salesforceUser = [\n 'Email' => null,\n ];\n\n app()->bind(QueryBuilder::class, function () {\n $queryBuilder = $this->createMock(QueryBuilder::class);\n $queryBuilder->expects($this->once())\n ->method('buildGetUsersQuery')\n ->with(null)\n ->willReturn('SELECT * FROM Users');\n\n return $queryBuilder;\n });\n\n $queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->exactly(2))\n ->method('query')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n\n $team = $this->createMock(Team::class);\n $user = $this->createMock(User::class);\n $this->mockTeamRepository($team, $salesforceUser, $user, false);\n\n $config = $this->createMock(Configuration::class);\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n\n $profile = $serviceMock->syncProfiles(null);\n\n $this->assertNull($profile);\n }\n\n public function testSyncProfilesUserToSearchMatchesCurrentUser(): void\n {\n $userToSearch = $this->createMock(User::class);\n $userToSearch->expects($this->once())\n ->method('getId')\n ->willReturn(123);\n\n $team = $this->createMock(Team::class);\n $config = $this->createMock(Configuration::class);\n $config->expects($this->once())\n ->method('getId')\n ->willReturn(1);\n\n $salesforceUser = [\n 'Email' => 'test@example.com',\n 'UserPreferencesLightningExperiencePreferred' => true,\n 'CallCenterId' => '123',\n 'Id' => '456',\n 'ProfileId' => '789',\n ];\n\n app()->bind(QueryBuilder::class, function () use ($userToSearch) {\n $queryBuilder = $this->createMock(QueryBuilder::class);\n $queryBuilder->expects($this->once())\n ->method('buildGetUsersQuery')\n ->with($userToSearch)\n ->willReturn('SELECT * FROM Users');\n\n return $queryBuilder;\n });\n\n $queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('query')\n ->with('SELECT * FROM Users')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n\n $user = $this->createMock(User::class);\n $user->expects($this->exactly(2))\n ->method('getId')\n ->willReturn(123);\n\n $this->mockTeamRepository($team, $salesforceUser, $user);\n\n $profileRepository = $this->createMock(ProfileRepository::class);\n $profileRepository->expects($this->once())\n ->method('updateOrCreateProfile')\n ->with(\n $user,\n [\n 'crm_configuration_id' => 1,\n 'crm_provider_id' => '456',\n ],\n [\n 'user_id' => 123,\n 'edition' => Profile::EDITION_LIGHTNING,\n 'has_external_cti' => true,\n 'crm_profile_id' => '789',\n ]\n )\n ->willReturn(new Profile());\n\n $this->app->instance(ProfileRepository::class, $profileRepository);\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n $profile = $serviceMock->syncProfiles($userToSearch);\n\n $this->assertInstanceOf(Profile::class, $profile);\n }\n\n public function testSyncProfilesWithCustomValidation(): void\n {\n $userToSearch = null;\n\n $team = $this->createMock(Team::class);\n $config = $this->createMockedConfiguration();\n $config->expects($this->atLeastOnce()) // Changed from once() to atLeastOnce()\n ->method('getId')\n ->willReturn(1);\n\n $salesforceUser = [\n 'Email' => 'test@example.com',\n 'UserPreferencesLightningExperiencePreferred' => true,\n 'CallCenterId' => '123',\n 'Id' => '456',\n 'ProfileId' => '789',\n 'CustomField' => 'CustomValue',\n ];\n\n $customRules = [\n ['field' => 'CustomField', 'value' => 'CustomValue'],\n ];\n\n $this->mockQueryBuilderAndHandler($userToSearch, [$salesforceUser]);\n\n $user = $this->createMock(User::class);\n $user->expects($this->once())\n ->method('getId')\n ->willReturn(123);\n\n $this->mockTeamRepository($team, $salesforceUser, $user, true, $customRules);\n\n $this->mockProfileRepository($user);\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n $profile = $serviceMock->syncProfiles($userToSearch);\n\n $this->assertNull($profile);\n }\n\n public function testSyncProfilesWithCustomValidationFailing(): void\n {\n $userToSearch = null;\n\n $team = $this->createMock(Team::class);\n $config = $this->createMockedConfiguration();\n\n $salesforceUser = [\n 'Email' => 'test@example.com',\n 'UserPreferencesLightningExperiencePreferred' => true,\n 'CallCenterId' => '123',\n 'Id' => '456',\n 'ProfileId' => '789',\n 'CustomField' => 'WrongValue',\n ];\n\n $customRules = [\n ['field' => 'CustomField', 'value' => 'CustomValue'],\n ];\n\n $this->mockQueryBuilderAndHandler($userToSearch, [$salesforceUser]);\n\n $teamRepository = $this->getMockForAbstractClass(TeamRepository::class, [], '', false, true, true, ['findActiveTeamMemberByEmail', 'getTeamSetting']);\n\n $teamSettings = $this->createMock(TeamSettings::class);\n $teamSettings->method('getValueType')\n ->willReturn('array');\n\n $teamSettings->method('getValue')\n ->willReturn(json_encode($customRules));\n\n $teamRepository->expects($this->once())\n ->method('getTeamSetting')\n ->with($team, 'custom_profile_validation')\n ->willReturn($teamSettings);\n\n app()->bind(TeamRepository::class, function () use ($teamRepository) {\n return $teamRepository;\n });\n\n $profileRepository = $this->createMock(ProfileRepository::class);\n $profileRepository->expects($this->never())\n ->method('updateOrCreateProfile');\n\n $this->app->instance(ProfileRepository::class, $profileRepository);\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n $result = $serviceMock->syncProfiles($userToSearch);\n\n $this->assertNull($result);\n }\n\n public function testGetContactRolesFromCrm(): void\n {\n $contactRoles = [\n [\n 'Id' => '1',\n 'ContactId' => 'Contact1',\n 'OpportunityId' => 'Opportunity1',\n 'Opportunity' => ['OwnerId' => 'Owner1'],\n 'IsPrimary' => true,\n 'Role' => 'Decision Maker',\n ],\n ];\n\n $expectedResponse = [\n [\n 'id' => '1',\n 'contactId' => 'Contact1',\n 'opportunityId' => 'Opportunity1',\n 'ownerId' => 'Owner1',\n 'isPrimary' => true,\n 'role' => 'Decision Maker',\n ],\n ];\n\n $this->bindQueryIterator($contactRoles);\n\n $serviceMock = $this->getServiceMock();\n\n $data = $serviceMock->getContactRolesFromCrm(now()->subDay());\n\n $this->assertEquals($expectedResponse, $data);\n }\n\n public function testGetContactRolesFromCrmNoResult(): void\n {\n $this->bindQueryIterator([]);\n\n $serviceMock = $this->getServiceMock();\n\n $data = $serviceMock->getContactRolesFromCrm(now()->subDay());\n\n $this->assertEquals([], $data);\n }\n\n public function testSyncContactRoles(): void\n {\n $contactRoles = [\n [\n 'id' => '1',\n 'contactId' => 'Contact1',\n 'opportunityId' => 'Opportunity1',\n 'ownerId' => 'Owner1',\n 'isPrimary' => true,\n 'role' => 'Decision Maker',\n ],\n ];\n\n app()->bind(ContactRoleRepository::class, function () {\n $contactRoleRepository = $this->createMock(ContactRoleRepository::class);\n $contactRoleRepository->expects($this->once())\n ->method('saveContactRoles');\n\n return $contactRoleRepository;\n });\n\n $serviceMock = $this->getServiceMock([\n 'getContactRolesFromCrm',\n 'syncRemotelyDeletedContactRoles',\n 'syncContact',\n 'syncOpportunity',\n ]);\n\n $config = $this->createMock(Configuration::class);\n $hasMany = $this->createMock(HasManyExtended::class);\n $hasMany->expects($this->exactly(2))\n ->method('where')\n ->willReturn($hasMany);\n\n $hasMany->expects($this->exactly(2))\n ->method('first')\n ->willReturn(\n $this->createMock(Contact::class),\n $this->createMock(Opportunity::class)\n );\n\n $config->expects($this->once())\n ->method('contacts')\n ->willReturn($hasMany);\n $config->expects($this->once())\n ->method('opportunities')\n ->willReturn($hasMany);\n\n $serviceMock->config = $config;\n\n $serviceMock->expects($this->once())\n ->method('getContactRolesFromCrm')\n ->willReturn($contactRoles);\n\n $serviceMock->expects($this->once())\n ->method('syncRemotelyDeletedContactRoles');\n\n $data = $serviceMock->syncContactRoles(now()->subDay());\n\n $this->assertEquals(1, $data);\n }\n\n public function testSyncRemotelyDeletedContactRoles(): void\n {\n $contactRoles = [\n [\n 'id' => '1',\n 'crm_provider_id' => '1',\n ],\n ];\n\n app()->bind(QueryHandler::class, function () use ($contactRoles) {\n $queryResults = new QueryResults($contactRoles, 1, true, null);\n\n $handler = $this->createMock(QueryHandler::class);\n $handler->method('queryDeleted')\n ->willReturn($queryResults);\n\n return $handler;\n });\n\n app()->bind(ContactRoleRepository::class, function () {\n $contactRoleRepository = $this->createMock(ContactRoleRepository::class);\n $contactRoleRepository->expects($this->once())\n ->method('deleteContactRoles');\n\n return $contactRoleRepository;\n });\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $this->createMock(Team::class);\n\n $data = $this->invokePrivateMethod('syncRemotelyDeletedContactRoles', $serviceMock, []);\n\n $this->assertTrue($data);\n }\n\n private function bindQueryIterator(array $queryResult): void\n {\n /** @var Client $client */\n $client = $this->createMock(Client::class);\n $queryIterator = new QueryIterator(\n $client,\n new QueryResults($queryResult, 1, true, null)\n );\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('query')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n }\n\n public static function getOpportunitySortOrderDataProvider(): array\n {\n return [\n 'all open recently updated' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED, ['LastModifiedDate DESC', true]],\n 'all open recently created' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED, ['CreatedDate DESC', true]],\n 'all open oldest created' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED, ['CreatedDate ASC', true]],\n 'all recently updated' => [Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED, ['LastModifiedDate DESC', false]],\n 'default' => ['unknown', ['LastModifiedDate DESC', true]],\n ];\n }\n\n private function createMockedConfiguration(): Configuration\n {\n $config = $this->createMock(Configuration::class);\n $profilesRelation = $this->getMockBuilder(\\Illuminate\\Database\\Eloquent\\Relations\\HasMany::class)\n ->disableOriginalConstructor()\n ->onlyMethods(['get'])\n ->addMethods(['where', 'first'])\n ->getMock();\n $profilesRelation->method('where')->willReturnSelf();\n $profilesRelation->method('get')->willReturn(collect([]));\n $profilesRelation->method('first')->willReturn(null);\n $config->method('profiles')->willReturn($profilesRelation);\n\n return $config;\n }\n\n private function getServiceMock(array $onlyMethods = []): MockObject&Service\n {\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $this->createMock(Client::class),\n $this->createMock(PayloadBuilder::class),\n $this->createMock(Dispatcher::class),\n $this->createMock(CountriesMap::class),\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods($onlyMethods)\n ->getMock();\n\n $serviceMock->profile = $this->createMock(Profile::class);\n\n return $serviceMock;\n }\n\n private function mockQueryBuilderAndHandler($userToSearch, $salesforceUsers): void\n {\n app()->bind(QueryBuilder::class, function () use ($userToSearch) {\n $queryBuilder = $this->createMock(QueryBuilder::class);\n $queryBuilder->expects($this->once())\n ->method('buildGetUsersQuery')\n ->with($userToSearch)\n ->willReturn('SELECT * FROM Users');\n\n return $queryBuilder;\n });\n\n $queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults($salesforceUsers, count($salesforceUsers), true, null));\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('query')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n }\n\n private function mockTeamRepository(\n Team $team,\n array $salesforceUser,\n ?User $user = null,\n bool $userSearch = true,\n array $customRules = []\n ): void {\n $teamRepository = $this->getMockForAbstractClass(TeamRepository::class, [], '', false, true, true, ['findActiveTeamMemberByEmail', 'getTeamSetting']);\n\n if ($userSearch) {\n $teamRepository->expects($this->once())\n ->method('findActiveTeamMemberByEmail')\n ->with($team, $salesforceUser['Email'])\n ->willReturn($user);\n }\n\n $teamSettings = $this->createMock(TeamSettings::class);\n $teamSettings->method('getValueType')\n ->willReturn('array');\n\n $teamSettings->method('getValue')\n ->willReturn(json_encode($customRules));\n\n $teamRepository->expects($this->once())\n ->method('getTeamSetting')\n ->with($team, 'custom_profile_validation')\n ->willReturn($teamSettings);\n\n app()->bind(TeamRepository::class, function () use ($teamRepository) {\n return $teamRepository;\n });\n }\n\n private function mockProfileRepository(User $user): void\n {\n $profileRepository = $this->createMock(ProfileRepository::class);\n $profileRepository->expects($this->once())\n ->method('updateOrCreateProfile')\n ->with(\n $user,\n [\n 'crm_configuration_id' => 1,\n 'crm_provider_id' => '456',\n ],\n [\n 'user_id' => 123,\n 'edition' => Profile::EDITION_LIGHTNING,\n 'has_external_cti' => true,\n 'crm_profile_id' => '789',\n ]\n )\n ->willReturn(new Profile());\n\n $this->app->instance(ProfileRepository::class, $profileRepository);\n }\n\n public function testBuildEnhancedNoteDecodesHtmlEntities(): void\n {\n $service = $this->getServiceMock(['createRecord']);\n\n $profile = new Profile();\n $profile->setAttribute('crm_provider_id', 'owner-123');\n $service->profile = $profile;\n\n $service->expects($this->exactly(2))\n ->method('createRecord')\n ->willReturnOnConsecutiveCalls('note-id-123', 'link-id-456');\n\n $bodyWithEntities = 'Welch's current challenges and Facebook's Club';\n\n $result = $this->invokePrivateMethod('buildEnhancedNote', $service, [\n 'Test Title',\n $bodyWithEntities,\n 'object-id-789',\n ]);\n\n $this->assertEquals('note-id-123', $result);\n }\n\n public function testBuildEnhancedNoteSanitizesWithoutQuotes(): void\n {\n $service = $this->getServiceMock(['createRecord']);\n\n $profile = new Profile();\n $profile->setAttribute('crm_provider_id', 'owner-456');\n $service->profile = $profile;\n\n $service->expects($this->exactly(2))\n ->method('createRecord')\n ->willReturnCallback(function ($type, $data) {\n if ($type === 'ContentNote') {\n $decoded = base64_decode($data['Content']);\n $this->assertStringContainsString(\"Welch's\", $decoded);\n $this->assertStringNotContainsString(''', $decoded);\n $this->assertStringNotContainsString('&#039;', $decoded);\n $this->assertStringContainsString('<script>', $decoded);\n\n return 'note-id-456';\n }\n\n return 'link-id-789';\n });\n\n $bodyWithMixedContent = \"Welch's and <script>alert('xss')</script>\";\n\n $result = $this->invokePrivateMethod('buildEnhancedNote', $service, [\n 'Test Title',\n $bodyWithMixedContent,\n 'object-id-123',\n ]);\n\n $this->assertEquals('note-id-456', $result);\n }\n\n public function testBuildEnhancedNoteConvertsLineBreaks(): void\n {\n $service = $this->getServiceMock(['createRecord']);\n\n $profile = new Profile();\n $profile->setAttribute('crm_provider_id', 'owner-789');\n $service->profile = $profile;\n\n $service->expects($this->exactly(2))\n ->method('createRecord')\n ->willReturnCallback(function ($type, $data) {\n if ($type === 'ContentNote') {\n $decoded = base64_decode($data['Content']);\n $this->assertStringContainsString('<br>', $decoded);\n $this->assertStringNotContainsString('<br />', $decoded);\n\n return 'note-id-789';\n }\n\n return 'link-id-012';\n });\n\n $bodyWithLineBreaks = \"Line 1\\nLine 2\\nLine 3\";\n\n $result = $this->invokePrivateMethod('buildEnhancedNote', $service, [\n 'Test Title',\n $bodyWithLineBreaks,\n 'object-id-456',\n ]);\n\n $this->assertEquals('note-id-789', $result);\n }\n\n public function testBuildEnhancedNoteHandlesComplexScenario(): void\n {\n $service = $this->getServiceMock(['createRecord']);\n\n $profile = new Profile();\n $profile->setAttribute('crm_provider_id', 'owner-complex');\n $service->profile = $profile;\n\n $service->expects($this->exactly(2))\n ->method('createRecord')\n ->willReturnCallback(function ($type, $data) {\n if ($type === 'ContentNote') {\n $decoded = base64_decode($data['Content']);\n\n $this->assertStringContainsString(\"Welch's\", $decoded);\n $this->assertStringContainsString(\"Facebook's Club\", $decoded);\n $this->assertStringContainsString(\"Arctics'\", $decoded);\n $this->assertStringNotContainsString(''', $decoded);\n $this->assertStringNotContainsString('&#039;', $decoded);\n $this->assertStringContainsString('<br>', $decoded);\n $this->assertStringContainsString('<', $decoded);\n $this->assertStringContainsString('>', $decoded);\n\n return 'note-complex';\n }\n\n return 'link-complex';\n });\n\n $complexBody = \"Summary:\\n---------\\nThe call focused on understanding Welch's current challenges and exploring how Arctics' Revenue Growth Management solutions could support their strategic goals.\\n\\n• John SMith discussed his role as a category advisor for Google and Facebook's Club, emphasizing the importance of market research and advising on product assortment.\\n• Madona introduced Arctics' Virtual Shoppers AI, which simulates consumer <behavior> to optimize pricing and promotional strategies.\";\n\n $result = $this->invokePrivateMethod('buildEnhancedNote', $service, [\n 'Jiminny Transcription Summary',\n $complexBody,\n 'task-id-001',\n ]);\n\n $this->assertEquals('note-complex', $result);\n }\n\n public function testSyncRemotelyDeletedObjectsWithErrorHandlingSuccess(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['syncRemotelyDeletedObjects'])\n ->getMock();\n\n // Mock team\n $team = $this->createMock(Team::class);\n $team->method('getUuid')->willReturn('team-uuid-123');\n $serviceMock->team = $team;\n\n // Expect syncRemotelyDeletedObjects to be called once and succeed\n $serviceMock->expects($this->once())\n ->method('syncRemotelyDeletedObjects')\n ->with(\\Jiminny\\Enums\\CrmObject::ACCOUNT);\n\n // Call the protected method using reflection\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');\n $method->setAccessible(true);\n\n // Should not throw any exceptions\n $method->invoke($serviceMock, \\Jiminny\\Enums\\CrmObject::ACCOUNT);\n\n $this->assertTrue(true); // Test completed successfully\n }\n\n public function testSyncRemotelyDeletedObjectsWithErrorHandlingFailure(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['syncRemotelyDeletedObjects'])\n ->getMock();\n\n // Mock team\n $team = $this->createMock(Team::class);\n $team->method('getUuid')->willReturn('team-uuid-456');\n $serviceMock->team = $team;\n\n // Mock logger to verify warning is logged\n $logger = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n\n // Use reflection to set the protected logger property\n $reflection = new \\ReflectionClass($serviceMock);\n $loggerProperty = $reflection->getProperty('logger');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($serviceMock, $logger);\n\n $exception = new \\Exception('Sync failed due to API error');\n\n // Expect syncRemotelyDeletedObjects to throw an exception\n $serviceMock->expects($this->once())\n ->method('syncRemotelyDeletedObjects')\n ->with(\\Jiminny\\Enums\\CrmObject::CONTACT)\n ->willThrowException($exception);\n\n // Expect warning to be logged with correct message and parameters\n $logger->expects($this->once())\n ->method('warning')\n ->with(\n '[Salesforce] Remotely deleted objects sync failed',\n [\n 'objectType' => 'contact',\n 'teamId' => 'team-uuid-456',\n 'reason' => 'Sync failed due to API error',\n ]\n );\n\n // Call the protected method using reflection\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');\n $method->setAccessible(true);\n\n // Should not re-throw the exception, just log it\n $method->invoke($serviceMock, \\Jiminny\\Enums\\CrmObject::CONTACT);\n\n $this->assertTrue(true); // Test completed successfully\n }\n\n public function testSyncRemotelyDeletedObjectsWithErrorHandlingWithLogParams(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['syncRemotelyDeletedObjects'])\n ->getMock();\n\n // Mock team\n $team = $this->createMock(Team::class);\n $team->method('getUuid')->willReturn('team-uuid-789');\n $serviceMock->team = $team;\n\n // Mock logger to verify warning is logged\n $logger = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n\n // Use reflection to set the protected logger property\n $loggerReflection = new \\ReflectionClass($serviceMock);\n $loggerProperty = $loggerReflection->getProperty('logger');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($serviceMock, $logger);\n\n $exception = new \\Exception('Network timeout');\n\n // Expect syncRemotelyDeletedObjects to throw an exception\n $serviceMock->expects($this->once())\n ->method('syncRemotelyDeletedObjects')\n ->with(\\Jiminny\\Enums\\CrmObject::OPPORTUNITY)\n ->willThrowException($exception);\n\n // Additional log parameters\n $logParams = [\n 'syncType' => 'full',\n 'batchSize' => 100,\n ];\n\n // Expect warning to be logged with merged parameters\n $logger->expects($this->once())\n ->method('warning')\n ->with(\n '[Salesforce] Remotely deleted objects sync failed',\n [\n 'objectType' => 'opportunity',\n 'teamId' => 'team-uuid-789',\n 'reason' => 'Network timeout',\n 'syncType' => 'full',\n 'batchSize' => 100,\n ]\n );\n\n // Call the protected method using reflection\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');\n $method->setAccessible(true);\n\n // Should not re-throw the exception, just log it\n $method->invoke($serviceMock, \\Jiminny\\Enums\\CrmObject::OPPORTUNITY, $logParams);\n\n $this->assertTrue(true); // Test completed successfully\n }\n\n /**\n * @dataProvider crmObjectProvider\n */\n public function testSyncRemotelyDeletedObjectsWithErrorHandlingDifferentCrmObjects(\\Jiminny\\Enums\\CrmObject $crmObject): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['syncRemotelyDeletedObjects'])\n ->getMock();\n\n // Mock team\n $team = $this->createMock(Team::class);\n $team->method('getUuid')->willReturn('team-uuid-test');\n $serviceMock->team = $team;\n\n // Mock logger to verify warning is logged\n $logger = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n\n // Use reflection to set the protected logger property\n $loggerReflectionClass = new \\ReflectionClass($serviceMock);\n $loggerProperty = $loggerReflectionClass->getProperty('logger');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($serviceMock, $logger);\n\n $exception = new \\Exception('Test error');\n\n // Expect syncRemotelyDeletedObjects to throw an exception\n $serviceMock->expects($this->once())\n ->method('syncRemotelyDeletedObjects')\n ->with($crmObject)\n ->willThrowException($exception);\n\n // Expect warning to be logged with correct entity type\n $logger->expects($this->once())\n ->method('warning')\n ->with(\n '[Salesforce] Remotely deleted objects sync failed',\n [\n 'objectType' => $crmObject->value,\n 'teamId' => 'team-uuid-test',\n 'reason' => 'Test error',\n ]\n );\n\n // Call the protected method using reflection\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');\n $method->setAccessible(true);\n\n $method->invoke($serviceMock, $crmObject);\n\n $this->assertTrue(true); // Test completed successfully\n }\n\n public function testHandleObjectDeletionWithDeletedEntity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['deleteCrmObject'])\n ->getMock();\n\n $entity = $this->createMock(\\Jiminny\\Models\\Account::class);\n $crmData = ['IsDeleted' => true];\n\n $serviceMock->expects($this->once())\n ->method('deleteCrmObject')\n ->with($entity);\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('handleObjectDeletion');\n $method->setAccessible(true);\n\n $method->invoke($serviceMock, $entity, $crmData);\n }\n\n public function testHandleObjectDeletionWithNonDeletedEntity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['deleteCrmObject'])\n ->getMock();\n\n $entity = $this->createMock(\\Jiminny\\Models\\Contact::class);\n $crmData = ['IsDeleted' => false];\n\n $serviceMock->expects($this->never())\n ->method('deleteCrmObject');\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('handleObjectDeletion');\n $method->setAccessible(true);\n\n $method->invoke($serviceMock, $entity, $crmData);\n }\n\n public function testDeleteCrmObjectWithValidEntity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['dispatchDeleteCrmObjectJob'])\n ->getMock();\n\n $entity = $this->createMock(\\Jiminny\\Models\\Lead::class);\n $entity->expects($this->once())->method('delete');\n\n $serviceMock->expects($this->once())\n ->method('dispatchDeleteCrmObjectJob')\n ->with($entity);\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('deleteCrmObject');\n $method->setAccessible(true);\n\n $method->invoke($serviceMock, $entity);\n }\n\n public function testDeleteCrmObjectWithNullEntity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['dispatchDeleteCrmObjectJob'])\n ->getMock();\n\n $serviceMock->expects($this->never())\n ->method('dispatchDeleteCrmObjectJob');\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('deleteCrmObject');\n $method->setAccessible(true);\n\n $method->invoke($serviceMock, null);\n }\n\n public function testDispatchDeleteCrmObjectJobWithNullEntity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($service);\n $method = $reflection->getMethod('dispatchDeleteCrmObjectJob');\n $method->setAccessible(true);\n\n // Should return early without dispatching - no exception expected\n $method->invoke($service, null);\n\n $this->assertTrue(true); // Test completed successfully\n }\n\n public function testDispatchDeleteCrmObjectJobWithUnsupportedEntity(): void\n {\n $this->expectException(\\TypeError::class);\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n $unsupportedEntity = $this->createMock(\\stdClass::class);\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($service);\n $method = $reflection->getMethod('dispatchDeleteCrmObjectJob');\n\n // This will throw TypeError due to union type constraint\n $method->invoke($service, $unsupportedEntity);\n }\n\n public function testHandleEntityDeletionByProviderIdMethodExists(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n // Test that the method exists and is accessible via reflection\n $reflection = new \\ReflectionClass($service);\n $method = $reflection->getMethod('handleEntityDeletionByProviderId');\n $method->setAccessible(true);\n\n // Verify method exists and has correct parameters\n $this->assertTrue($method->isProtected());\n $this->assertEquals(2, $method->getNumberOfParameters());\n\n $parameters = $method->getParameters();\n $this->assertEquals('targetEntity', $parameters[0]->getName());\n $this->assertEquals('crmData', $parameters[1]->getName());\n }\n\n public function testSyncRemotelyDeletedObjectsWithNoResults(): void\n {\n // Create a real service instance to avoid mock property issues\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n // Mock queryHandler to throw NoResultsException\n $queryHandler = $this->createMock(QueryHandler::class);\n $queryHandler->expects($this->once())\n ->method('queryDeleted')\n ->with('Opportunity')\n ->willThrowException(new NoResultsException('No results'));\n\n // Set the queryHandler using reflection on the real service\n $reflection = new \\ReflectionClass($service);\n $queryHandlerProperty = $reflection->getProperty('queryHandler');\n $queryHandlerProperty->setAccessible(true);\n $queryHandlerProperty->setValue($service, $queryHandler);\n\n $result = self::invokePrivateMethod('syncRemotelyDeletedObjects', $service, [CrmObject::OPPORTUNITY]);\n\n $this->assertFalse($result);\n }\n\n public function testSyncRemotelyDeletedObjectsWithEmptyResults(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n // Mock queryHandler to return empty results\n $queryResult = $this->createMock(QueryResults::class);\n $queryResult->method('getResults')->willReturn([]);\n\n $queryHandler = $this->createMock(QueryHandler::class);\n $queryHandler->expects($this->once())\n ->method('queryDeleted')\n ->with('Opportunity')\n ->willReturn($queryResult);\n\n // Set the queryHandler using reflection on the real service\n $reflection = new \\ReflectionClass($service);\n $queryHandlerProperty = $reflection->getProperty('queryHandler');\n $queryHandlerProperty->setAccessible(true);\n $queryHandlerProperty->setValue($service, $queryHandler);\n\n $result = self::invokePrivateMethod('syncRemotelyDeletedObjects', $service, [CrmObject::OPPORTUNITY]);\n\n $this->assertFalse($result);\n }\n\n public function testSyncRemotelyDeletedObjectsWithUnsupportedCrmObject(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n // Mock queryHandler to return some deleted objects so we reach the match statement\n $deletedObjects = [\n ['id' => 'task1'],\n ['id' => 'task2'],\n ];\n $queryResult = $this->createMock(QueryResults::class);\n $queryResult->method('getResults')->willReturn($deletedObjects);\n\n $queryHandler = $this->createMock(QueryHandler::class);\n $queryHandler->expects($this->once())\n ->method('queryDeleted')\n ->with('Task') // ucfirst('task') = 'Task'\n ->willReturn($queryResult);\n\n self::setPrivateProperty($service, 'queryHandler', $queryHandler);\n\n $this->expectException(InvalidArgumentException::class);\n $this->expectExceptionMessage('Unsupported CrmObject: task');\n\n self::invokePrivateMethod('syncRemotelyDeletedObjects', $service, [CrmObject::TASK]);\n }\n\n public static function crmObjectProvider(): array\n {\n return [\n 'Account' => [CrmObject::ACCOUNT],\n 'Contact' => [CrmObject::CONTACT],\n 'Lead' => [CrmObject::LEAD],\n 'Opportunity' => [CrmObject::OPPORTUNITY],\n ];\n }\n\n public function testVerifyTaskExistsReturnsTrue(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-123', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-123');\n $activity->method('getId')->willReturn(456);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-123', ['Id', 'IsDeleted'])\n ->willReturn(['Id' => 'task-123', 'IsDeleted' => false]);\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertTrue($result);\n }\n\n public function testVerifyTaskExistsReturnsTrueForEvent(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:event-123', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('event-123');\n $activity->method('getId')->willReturn(456);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_EVENT);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Event', 'event-123', ['Id', 'IsDeleted'])\n ->willReturn(['Id' => 'event-123', 'IsDeleted' => false]);\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertTrue($result);\n }\n\n public function testVerifyTaskExistsReturnsFalseWhenDeleted(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-456', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-456');\n $activity->method('getId')->willReturn(789);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-456', ['Id', 'IsDeleted'])\n ->willReturn(['Id' => 'task-456', 'IsDeleted' => true]);\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertFalse($result);\n }\n\n public function testVerifyTaskExistsReturnsFalseWhenNotFound(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-999', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-999');\n $activity->method('getId')->willReturn(999);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-999', ['Id', 'IsDeleted'])\n ->willThrowException(new \\Jiminny\\Exceptions\\HttpNotFoundException('Task not found'));\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertFalse($result);\n }\n\n public function testVerifyTaskExistsReturnsFalseWhenNoPlaybook(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-no-playbook', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-no-playbook');\n $activity->method('getId')->willReturn(111);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn(null);\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertFalse($result);\n }\n\n public function testVerifyTaskExistsThrowsExceptionForTransientErrors(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-error', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-error');\n $activity->method('getId')->willReturn(888);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-error', ['Id', 'IsDeleted'])\n ->willThrowException(new \\RuntimeException('Network timeout'));\n\n $this->expectException(\\RuntimeException::class);\n $this->expectExceptionMessage('Network timeout');\n\n $service->verifyTaskExists($activity);\n }\n\n public function testVerifyTaskExistsCachesResults(): void\n {\n $cachedValue = null;\n Cache::shouldReceive('remember')\n ->twice()\n ->with('crm_task_exists:123:task-cached', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(function ($key, $ttl, $callback) use (&$cachedValue) {\n if ($cachedValue === null) {\n $cachedValue = $callback();\n }\n\n return $cachedValue;\n });\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-cached');\n $activity->method('getId')->willReturn(555);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-cached', ['Id', 'IsDeleted'])\n ->willReturn(['Id' => 'task-cached', 'IsDeleted' => false]);\n\n $result1 = $service->verifyTaskExists($activity);\n $result2 = $service->verifyTaskExists($activity);\n\n $this->assertTrue($result1);\n $this->assertTrue($result2);\n }\n\n public function testVerifyTaskExistsReturnsFalseForHttpBadRequestException(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-400', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-400');\n $activity->method('getId')->willReturn(400);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-400', ['Id', 'IsDeleted'])\n ->willThrowException(new \\Jiminny\\Exceptions\\HttpBadRequestException('Bad request'));\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertFalse($result);\n }\n\n public function testImportOpportunitySkipsWhenNoProfileAndNoAccount(): void\n {\n $crmData = [\n 'Id' => 'SF-NO-USER-1',\n 'Name' => 'Test Opportunity',\n 'OwnerId' => 'owner-no-profile',\n // No AccountId\n ];\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(\\Illuminate\\Events\\Dispatcher::class); // ← ADD THIS\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n\n $service = new Service(\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService\n );\n\n $config = $this->createMock(Configuration::class);\n\n // Mock profiles relation returning null (no profile found)\n $profilesRelation = \\Mockery::mock(\\Illuminate\\Database\\Eloquent\\Relations\\HasMany::class);\n $profilesRelation->shouldReceive('where')->with('crm_provider_id', 'owner-no-profile')->andReturnSelf();\n $profilesRelation->shouldReceive('first')->andReturn(null);\n\n $config->expects($this->once())\n ->method('profiles')\n ->willReturn($profilesRelation);\n\n $team = $this->createMock(Team::class);\n $team->method('getId')->willReturn(1);\n\n $logger = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n $logger->expects($this->once())\n ->method('error')\n ->with(\n '[Salesforce] | Skip import, no user_id found',\n ['id' => 'SF-NO-USER-1']\n );\n\n $reflection = new \\ReflectionClass($service);\n\n $configProperty = $reflection->getProperty('config');\n $configProperty->setAccessible(true);\n $configProperty->setValue($service, $config);\n\n $teamProperty = $reflection->getProperty('team');\n $teamProperty->setAccessible(true);\n $teamProperty->setValue($service, $team);\n\n $loggerProperty = $reflection->getProperty('logger');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($service, $logger);\n\n // Initialize profile property to avoid \"must not be accessed before initialization\" error\n $profileProperty = $reflection->getProperty('profile');\n $profileProperty->setAccessible(true);\n $profileProperty->setValue($service, null);\n\n $result = self::invokePrivateMethod('importOpportunity', $service, [$crmData]);\n\n $this->assertNull($result);\n }\n\n public function testImportOpportunitySkipsWhenProfileNotFound(): void\n {\n $crmData = [\n 'Id' => 'SF-NO-USER-2',\n 'Name' => 'Test Opportunity',\n 'OwnerId' => 'owner-not-found',\n // No AccountId - avoid complex account processing\n ];\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $eventDispatcher = $this->createMock(\\Illuminate\\Events\\Dispatcher::class); // ← ADD THIS\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n\n\n $service = new Service(\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService\n );\n\n $config = $this->createMock(Configuration::class);\n\n // Mock profiles relation returning null (no profile found)\n $profilesRelation = \\Mockery::mock(\\Illuminate\\Database\\Eloquent\\Relations\\HasMany::class);\n $profilesRelation->shouldReceive('where')->with('crm_provider_id', 'owner-not-found')->andReturnSelf();\n $profilesRelation->shouldReceive('first')->andReturn(null);\n\n $config->expects($this->once())\n ->method('profiles')\n ->willReturn($profilesRelation);\n\n $team = $this->createMock(Team::class);\n $team->method('getId')->willReturn(1);\n\n $logger = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n $logger->expects($this->once())\n ->method('error')\n ->with(\n '[Salesforce] | Skip import, no user_id found',\n ['id' => 'SF-NO-USER-2']\n );\n\n $reflection = new \\ReflectionClass($service);\n\n $configProperty = $reflection->getProperty('config');\n $configProperty->setAccessible(true);\n $configProperty->setValue($service, $config);\n\n $teamProperty = $reflection->getProperty('team');\n $teamProperty->setAccessible(true);\n $teamProperty->setValue($service, $team);\n\n $loggerProperty = $reflection->getProperty('logger');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($service, $logger);\n\n // Initialize profile property\n $profileProperty = $reflection->getProperty('profile');\n $profileProperty->setAccessible(true);\n $profileProperty->setValue($service, null);\n\n $result = self::invokePrivateMethod('importOpportunity', $service, [$crmData]);\n\n $this->assertNull($result);\n }\n}\n\nclass HasManyExtended extends HasMany\n{\n public function where()\n {\n }\n\n public function first()\n {\n }\n}","depth":4,"bounds":{"left":0.16223404,"top":0.09736632,"width":0.3550532,"height":0.90263367},"on_screen":true,"value":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Services\\Crm\\Salesforce;\n\nuse Illuminate\\Database\\Eloquent\\Relations\\HasMany;\nuse Illuminate\\Events\\Dispatcher;\nuse Illuminate\\Support\\Facades\\Cache;\nuse Jiminny\\Component\\Country\\CountriesMap;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Enums\\CrmObject;\nuse Jiminny\\Exceptions\\InvalidArgumentException;\nuse Jiminny\\Exceptions\\NoResultsException;\nuse Jiminny\\Integrations\\PlaybookResolver;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Crm\\Configuration;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\TeamSettings;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\ContactRoleRepository;\nuse Jiminny\\Repositories\\Crm\\FieldDataRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\Salesforce\\Client;\nuse Jiminny\\Services\\Crm\\Salesforce\\PayloadBuilder;\nuse Jiminny\\Services\\Crm\\Salesforce\\QueryBuilder;\nuse Jiminny\\Services\\Crm\\Salesforce\\QueryHandler;\nuse Jiminny\\Services\\Crm\\Salesforce\\QueryIterator;\nuse Jiminny\\Services\\Crm\\Salesforce\\QueryResults;\nuse Jiminny\\Services\\Crm\\Salesforce\\Service;\nuse PHPUnit\\Framework\\MockObject\\MockObject;\nuse Tests\\TestCase;\nuse Tests\\Unit\\Traits\\TestPrivateMethod;\n\nclass ServiceTest extends TestCase\n{\n use TestPrivateMethod;\n\n public function testFetchAndAssociateRelatedActivity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $payloadBuilder->method('addCustomLogicFieldsPayload')\n ->willReturnCallback(function ($activity, $payload) {\n return $payload;\n });\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods(['fetchRelatedActivity', 'getPlaybook', 'getPlaybookCategory', 'updateRecord'])\n ->getMock();\n\n $serviceMock->expects($this->once())\n ->method('fetchRelatedActivity')\n ->willReturn([\n 'Id' => 'testId',\n 'Type' => null,\n 'OwnerId' => 'testerUser',\n 'Description' => 'Test description',\n ]);\n\n $user = $this->createMock(User::class);\n $team = $this->createMock(Team::class);\n $user->method('getAttribute')->with('team')->willReturn($team);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityField')->willReturn(null);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_EVENT);\n\n $serviceMock->expects($this->once())\n ->method('getPlaybook')\n ->with($user)\n ->willReturn($playbook);\n\n $serviceMock->expects($this->never())\n ->method('getPlaybookCategory');\n\n $serviceMock->expects($this->never())\n ->method('updateRecord');\n\n $fieldDataRepository = $this->createMock(FieldDataRepository::class);\n $fieldDataRepository->method('getActivityFieldData')->willReturn(collect([]));\n app()->instance(FieldDataRepository::class, $fieldDataRepository);\n\n $config = $this->createMock(Configuration::class);\n $profilesRelation = $this->getMockBuilder(\\Illuminate\\Database\\Eloquent\\Relations\\HasMany::class)\n ->disableOriginalConstructor()\n ->onlyMethods(['get'])\n ->addMethods(['where'])\n ->getMock();\n $profilesRelation->method('where')->willReturnSelf();\n $profilesRelation->method('get')->willReturn(collect([]));\n $config->method('profiles')->willReturn($profilesRelation);\n\n $serviceMock->config = $config;\n $serviceMock->profile = null;\n\n $actualStartTime = \\Carbon\\Carbon::now();\n\n $activity = $this->getMockBuilder(Activity::class)\n ->disableOriginalConstructor()\n ->onlyMethods(['update', 'hasProspect'])\n ->getMock();\n\n $activity->method('update')->willReturn(true);\n $activity->method('hasProspect')->willReturn(true);\n\n $activity->type = Activity::TYPE_CONFERENCE;\n $activity->provider = Activity::PROVIDER_TWILIO;\n $activity->lead_id = 1;\n $activity->user_id = 0;\n $activity->id_string = 'test-activity-id';\n $activity->user = $user;\n\n $activity->actual_start_time = $actualStartTime;\n $activity->uuid = 'c53d8320-f556-4cee-a2f8-5f232f454ca4';\n\n app()->bind(PlaybookResolver::class, function () use ($user) {\n $playbook = $this->createMock(Playbook::class);\n $playbookResolver = $this->createMock(PlaybookResolver::class);\n $playbookResolver->expects($this->once())\n ->method('resolvePlaybookByUser')\n ->with($user)\n ->willReturn($playbook);\n\n return $playbookResolver;\n });\n\n $data = $serviceMock->fetchAndAssociateRelatedActivity($activity);\n\n $this->assertInstanceOf(Activity::class, $data);\n $this->assertEquals(Activity::TYPE_CONFERENCE, $data->getType());\n $this->assertEquals($actualStartTime->getTimestamp(), $data->getActualStartTime()->getTimestamp());\n }\n\n public function testFetchAndAssociateRelatedActivitySkipsForTaskBasedPlaybook(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods(['fetchRelatedActivity', 'getPlaybook'])\n ->getMock();\n\n $user = $this->createMock(User::class);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n $playbook->method('getId')->willReturn(123);\n\n $serviceMock->expects($this->once())\n ->method('getPlaybook')\n ->with($user)\n ->willReturn($playbook);\n\n $serviceMock->expects($this->never())\n ->method('fetchRelatedActivity');\n\n $activity = $this->getMockBuilder(Activity::class)\n ->disableOriginalConstructor()\n ->onlyMethods(['hasProspect', 'getUuid'])\n ->getMock();\n\n $activity->method('hasProspect')->willReturn(true);\n $activity->method('getUuid')->willReturn('c53d8320-f556-4cee-a2f8-5f232f454ca4');\n $activity->type = Activity::TYPE_CONFERENCE;\n $activity->actual_start_time = \\Carbon\\Carbon::now();\n $activity->user = $user;\n\n $result = $serviceMock->fetchAndAssociateRelatedActivity($activity);\n\n $this->assertNull($result);\n }\n\n public function testFetchAndAssociateRelatedActivityReturnsNullForNonConference(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $serviceMock = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)\n );\n\n $activity = $this->getMockBuilder(Activity::class)\n ->disableOriginalConstructor()\n ->getMock();\n\n $activity->type = Activity::TYPE_SOFTPHONE;\n\n $result = $serviceMock->fetchAndAssociateRelatedActivity($activity);\n\n $this->assertNull($result);\n }\n\n public function testFetchAndAssociateRelatedActivityReturnsNullWhenNoStartTime(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $serviceMock = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)\n );\n\n\n $activity = $this->getMockBuilder(Activity::class)\n ->disableOriginalConstructor()\n ->getMock();\n\n $activity->type = Activity::TYPE_CONFERENCE;\n $activity->actual_start_time = null;\n $activity->scheduled_start_time = null;\n\n $result = $serviceMock->fetchAndAssociateRelatedActivity($activity);\n\n $this->assertNull($result);\n }\n\n public function testFetchAndAssociateRelatedActivityReturnsNullWhenNoProspect(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods(['getPlaybook'])\n ->getMock();\n\n $serviceMock->expects($this->never())\n ->method('getPlaybook');\n\n $activity = $this->getMockBuilder(Activity::class)\n ->disableOriginalConstructor()\n ->onlyMethods(['hasProspect', 'getUuid'])\n ->getMock();\n\n $activity->method('hasProspect')->willReturn(false);\n $activity->method('getUuid')->willReturn('c53d8320-f556-4cee-a2f8-5f232f454ca4');\n $activity->type = Activity::TYPE_CONFERENCE;\n $activity->actual_start_time = \\Carbon\\Carbon::now();\n\n $result = $serviceMock->fetchAndAssociateRelatedActivity($activity);\n\n $this->assertNull($result);\n }\n\n public function testMatchExactlyByEmail(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods([])\n ->getMock();\n\n $profile = new Profile();\n $profile->setAttribute('id', bin2hex(random_bytes(8)));\n $serviceMock->profile = $profile;\n\n $team = $this->createMock(Team::class);\n $serviceMock->team = $team;\n\n $data = $serviceMock->matchExactlyByEmail(bin2hex(random_bytes(8)) . 'test_email@testserver.com');\n\n $this->assertEquals(null, $data);\n }\n\n public function testMatchDomainFromEmail(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $queryIterator = $this->createMock(QueryIterator::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $config = $this->createMock(Configuration::class);\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('search')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods(['convertCrmData'])\n ->getMock();\n\n $profile = new Profile();\n $profile->account_fields = 'Field1, Field2, Field3';\n $serviceMock->profile = $profile;\n\n $serviceMock->expects($this->once())\n ->method('convertCrmData')\n ->willReturn(['test']);\n\n $this->app->bind(QueryBuilder::class, function () {\n $queryBuilder = $this->createMock(QueryBuilder::class);\n $queryBuilder->expects($this->once())\n ->method('buildMatchByDomainQuery')\n ->with('test_email@testserver.com')\n ->willReturn('FIND {test_email@testserver.com} IN ALL FIELDS RETURNING Account(Id)');\n\n return $queryBuilder;\n });\n\n $team = $this->createMock(Team::class);\n\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n\n $data = $serviceMock->matchByDomain('test_email@testserver.com');\n\n $this->assertEquals(['test'], $data);\n }\n\n public function testBuildTaskSearchFields(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)\n );\n\n $fields = $service->buildTaskSearchFields();\n\n $expectedFields = ['Id', 'WhoId', 'WhatId', 'AccountId'];\n\n $this->assertEquals($expectedFields, $fields);\n }\n\n public function testMapCrmObjects(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class),\n );\n\n $sampleTask = [\n 'WhoId' => '003sampleWhoId',\n 'AccountId' => 'sampleAccountId',\n 'WhatId' => 'sampleWhatId',\n ];\n\n $activityData = $service->mapCrmObjects($sampleTask);\n\n $expectedActivityData = [\n 'contact' => '003sampleWhoId',\n 'account' => 'sampleAccountId',\n 'opportunity' => 'sampleWhatId',\n ];\n\n $this->assertEquals($expectedActivityData, $activityData);\n }\n\n public function testGetInstalledAppVersion(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n $queryIterator = $this->createMock(QueryIterator::class);\n $queryIterator->expects($this->any())\n ->method('current')->willReturn([\n 'SubscriberPackageVersion' => [\n 'MajorVersion' => '1',\n 'MinorVersion' => '0',\n 'PatchVersion' => '1',\n 'BuildNumber' => '0',\n ],\n ]);\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('metadata')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods(array_diff(get_class_methods(Service::class), ['getInstalledAppVersion']))\n ->getMock();\n\n $version = $serviceMock->getInstalledAppVersion();\n\n $this->assertEquals('1010', $version);\n }\n\n public function testSyncProfiles(): void\n {\n $userToSearch = null;\n\n $team = $this->createMock(Team::class);\n $config = $this->createMockedConfiguration();\n $config->expects($this->once())\n ->method('getId')\n ->willReturn(1);\n\n $salesforceUser = [\n 'Email' => 'test@example.com',\n 'UserPreferencesLightningExperiencePreferred' => true,\n 'CallCenterId' => '123',\n 'Id' => '456',\n 'ProfileId' => '789',\n ];\n\n app()->bind(QueryBuilder::class, function () use ($userToSearch) {\n $queryBuilder = $this->createMock(QueryBuilder::class);\n $queryBuilder->expects($this->once())\n ->method('buildGetUsersQuery')\n ->with($userToSearch)\n ->willReturn('SELECT * FROM Users');\n\n return $queryBuilder;\n });\n\n $queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('query')\n ->with('SELECT * FROM Users')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n\n $user = $this->createMock(User::class);\n $user->expects($this->once())\n ->method('getId')\n ->willReturn(123);\n\n $this->mockTeamRepository($team, $salesforceUser, $user);\n\n $profileRepository = $this->createMock(ProfileRepository::class);\n $profileRepository->expects($this->once())\n ->method('updateOrCreateProfile')\n ->with(\n $user,\n [\n 'crm_configuration_id' => 1,\n 'crm_provider_id' => '456',\n ],\n [\n 'user_id' => 123,\n 'edition' => Profile::EDITION_LIGHTNING,\n 'has_external_cti' => true,\n 'crm_profile_id' => '789',\n ]\n )\n ->willReturn(new Profile());\n\n $this->app->instance(ProfileRepository::class, $profileRepository);\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n $result = $serviceMock->syncProfiles($userToSearch);\n\n $this->assertNull($result);\n }\n\n public function testSyncProfilesEmailIsNull(): void\n {\n $userToSearch = $this->createMock(User::class);\n\n $salesforceUser = [\n 'Email' => null,\n ];\n\n app()->bind(QueryBuilder::class, function () {\n $queryBuilder = $this->createMock(QueryBuilder::class);\n $queryBuilder->expects($this->once())\n ->method('buildGetUsersQuery')\n ->with(null)\n ->willReturn('SELECT * FROM Users');\n\n return $queryBuilder;\n });\n\n $queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->exactly(2))\n ->method('query')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n\n $team = $this->createMock(Team::class);\n $user = $this->createMock(User::class);\n $this->mockTeamRepository($team, $salesforceUser, $user, false);\n\n $config = $this->createMock(Configuration::class);\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n\n $profile = $serviceMock->syncProfiles(null);\n\n $this->assertNull($profile);\n }\n\n public function testSyncProfilesUserToSearchMatchesCurrentUser(): void\n {\n $userToSearch = $this->createMock(User::class);\n $userToSearch->expects($this->once())\n ->method('getId')\n ->willReturn(123);\n\n $team = $this->createMock(Team::class);\n $config = $this->createMock(Configuration::class);\n $config->expects($this->once())\n ->method('getId')\n ->willReturn(1);\n\n $salesforceUser = [\n 'Email' => 'test@example.com',\n 'UserPreferencesLightningExperiencePreferred' => true,\n 'CallCenterId' => '123',\n 'Id' => '456',\n 'ProfileId' => '789',\n ];\n\n app()->bind(QueryBuilder::class, function () use ($userToSearch) {\n $queryBuilder = $this->createMock(QueryBuilder::class);\n $queryBuilder->expects($this->once())\n ->method('buildGetUsersQuery')\n ->with($userToSearch)\n ->willReturn('SELECT * FROM Users');\n\n return $queryBuilder;\n });\n\n $queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('query')\n ->with('SELECT * FROM Users')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n\n $user = $this->createMock(User::class);\n $user->expects($this->exactly(2))\n ->method('getId')\n ->willReturn(123);\n\n $this->mockTeamRepository($team, $salesforceUser, $user);\n\n $profileRepository = $this->createMock(ProfileRepository::class);\n $profileRepository->expects($this->once())\n ->method('updateOrCreateProfile')\n ->with(\n $user,\n [\n 'crm_configuration_id' => 1,\n 'crm_provider_id' => '456',\n ],\n [\n 'user_id' => 123,\n 'edition' => Profile::EDITION_LIGHTNING,\n 'has_external_cti' => true,\n 'crm_profile_id' => '789',\n ]\n )\n ->willReturn(new Profile());\n\n $this->app->instance(ProfileRepository::class, $profileRepository);\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n $profile = $serviceMock->syncProfiles($userToSearch);\n\n $this->assertInstanceOf(Profile::class, $profile);\n }\n\n public function testSyncProfilesWithCustomValidation(): void\n {\n $userToSearch = null;\n\n $team = $this->createMock(Team::class);\n $config = $this->createMockedConfiguration();\n $config->expects($this->atLeastOnce()) // Changed from once() to atLeastOnce()\n ->method('getId')\n ->willReturn(1);\n\n $salesforceUser = [\n 'Email' => 'test@example.com',\n 'UserPreferencesLightningExperiencePreferred' => true,\n 'CallCenterId' => '123',\n 'Id' => '456',\n 'ProfileId' => '789',\n 'CustomField' => 'CustomValue',\n ];\n\n $customRules = [\n ['field' => 'CustomField', 'value' => 'CustomValue'],\n ];\n\n $this->mockQueryBuilderAndHandler($userToSearch, [$salesforceUser]);\n\n $user = $this->createMock(User::class);\n $user->expects($this->once())\n ->method('getId')\n ->willReturn(123);\n\n $this->mockTeamRepository($team, $salesforceUser, $user, true, $customRules);\n\n $this->mockProfileRepository($user);\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n $profile = $serviceMock->syncProfiles($userToSearch);\n\n $this->assertNull($profile);\n }\n\n public function testSyncProfilesWithCustomValidationFailing(): void\n {\n $userToSearch = null;\n\n $team = $this->createMock(Team::class);\n $config = $this->createMockedConfiguration();\n\n $salesforceUser = [\n 'Email' => 'test@example.com',\n 'UserPreferencesLightningExperiencePreferred' => true,\n 'CallCenterId' => '123',\n 'Id' => '456',\n 'ProfileId' => '789',\n 'CustomField' => 'WrongValue',\n ];\n\n $customRules = [\n ['field' => 'CustomField', 'value' => 'CustomValue'],\n ];\n\n $this->mockQueryBuilderAndHandler($userToSearch, [$salesforceUser]);\n\n $teamRepository = $this->getMockForAbstractClass(TeamRepository::class, [], '', false, true, true, ['findActiveTeamMemberByEmail', 'getTeamSetting']);\n\n $teamSettings = $this->createMock(TeamSettings::class);\n $teamSettings->method('getValueType')\n ->willReturn('array');\n\n $teamSettings->method('getValue')\n ->willReturn(json_encode($customRules));\n\n $teamRepository->expects($this->once())\n ->method('getTeamSetting')\n ->with($team, 'custom_profile_validation')\n ->willReturn($teamSettings);\n\n app()->bind(TeamRepository::class, function () use ($teamRepository) {\n return $teamRepository;\n });\n\n $profileRepository = $this->createMock(ProfileRepository::class);\n $profileRepository->expects($this->never())\n ->method('updateOrCreateProfile');\n\n $this->app->instance(ProfileRepository::class, $profileRepository);\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n $result = $serviceMock->syncProfiles($userToSearch);\n\n $this->assertNull($result);\n }\n\n public function testGetContactRolesFromCrm(): void\n {\n $contactRoles = [\n [\n 'Id' => '1',\n 'ContactId' => 'Contact1',\n 'OpportunityId' => 'Opportunity1',\n 'Opportunity' => ['OwnerId' => 'Owner1'],\n 'IsPrimary' => true,\n 'Role' => 'Decision Maker',\n ],\n ];\n\n $expectedResponse = [\n [\n 'id' => '1',\n 'contactId' => 'Contact1',\n 'opportunityId' => 'Opportunity1',\n 'ownerId' => 'Owner1',\n 'isPrimary' => true,\n 'role' => 'Decision Maker',\n ],\n ];\n\n $this->bindQueryIterator($contactRoles);\n\n $serviceMock = $this->getServiceMock();\n\n $data = $serviceMock->getContactRolesFromCrm(now()->subDay());\n\n $this->assertEquals($expectedResponse, $data);\n }\n\n public function testGetContactRolesFromCrmNoResult(): void\n {\n $this->bindQueryIterator([]);\n\n $serviceMock = $this->getServiceMock();\n\n $data = $serviceMock->getContactRolesFromCrm(now()->subDay());\n\n $this->assertEquals([], $data);\n }\n\n public function testSyncContactRoles(): void\n {\n $contactRoles = [\n [\n 'id' => '1',\n 'contactId' => 'Contact1',\n 'opportunityId' => 'Opportunity1',\n 'ownerId' => 'Owner1',\n 'isPrimary' => true,\n 'role' => 'Decision Maker',\n ],\n ];\n\n app()->bind(ContactRoleRepository::class, function () {\n $contactRoleRepository = $this->createMock(ContactRoleRepository::class);\n $contactRoleRepository->expects($this->once())\n ->method('saveContactRoles');\n\n return $contactRoleRepository;\n });\n\n $serviceMock = $this->getServiceMock([\n 'getContactRolesFromCrm',\n 'syncRemotelyDeletedContactRoles',\n 'syncContact',\n 'syncOpportunity',\n ]);\n\n $config = $this->createMock(Configuration::class);\n $hasMany = $this->createMock(HasManyExtended::class);\n $hasMany->expects($this->exactly(2))\n ->method('where')\n ->willReturn($hasMany);\n\n $hasMany->expects($this->exactly(2))\n ->method('first')\n ->willReturn(\n $this->createMock(Contact::class),\n $this->createMock(Opportunity::class)\n );\n\n $config->expects($this->once())\n ->method('contacts')\n ->willReturn($hasMany);\n $config->expects($this->once())\n ->method('opportunities')\n ->willReturn($hasMany);\n\n $serviceMock->config = $config;\n\n $serviceMock->expects($this->once())\n ->method('getContactRolesFromCrm')\n ->willReturn($contactRoles);\n\n $serviceMock->expects($this->once())\n ->method('syncRemotelyDeletedContactRoles');\n\n $data = $serviceMock->syncContactRoles(now()->subDay());\n\n $this->assertEquals(1, $data);\n }\n\n public function testSyncRemotelyDeletedContactRoles(): void\n {\n $contactRoles = [\n [\n 'id' => '1',\n 'crm_provider_id' => '1',\n ],\n ];\n\n app()->bind(QueryHandler::class, function () use ($contactRoles) {\n $queryResults = new QueryResults($contactRoles, 1, true, null);\n\n $handler = $this->createMock(QueryHandler::class);\n $handler->method('queryDeleted')\n ->willReturn($queryResults);\n\n return $handler;\n });\n\n app()->bind(ContactRoleRepository::class, function () {\n $contactRoleRepository = $this->createMock(ContactRoleRepository::class);\n $contactRoleRepository->expects($this->once())\n ->method('deleteContactRoles');\n\n return $contactRoleRepository;\n });\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $this->createMock(Team::class);\n\n $data = $this->invokePrivateMethod('syncRemotelyDeletedContactRoles', $serviceMock, []);\n\n $this->assertTrue($data);\n }\n\n private function bindQueryIterator(array $queryResult): void\n {\n /** @var Client $client */\n $client = $this->createMock(Client::class);\n $queryIterator = new QueryIterator(\n $client,\n new QueryResults($queryResult, 1, true, null)\n );\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('query')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n }\n\n public static function getOpportunitySortOrderDataProvider(): array\n {\n return [\n 'all open recently updated' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED, ['LastModifiedDate DESC', true]],\n 'all open recently created' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED, ['CreatedDate DESC', true]],\n 'all open oldest created' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED, ['CreatedDate ASC', true]],\n 'all recently updated' => [Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED, ['LastModifiedDate DESC', false]],\n 'default' => ['unknown', ['LastModifiedDate DESC', true]],\n ];\n }\n\n private function createMockedConfiguration(): Configuration\n {\n $config = $this->createMock(Configuration::class);\n $profilesRelation = $this->getMockBuilder(\\Illuminate\\Database\\Eloquent\\Relations\\HasMany::class)\n ->disableOriginalConstructor()\n ->onlyMethods(['get'])\n ->addMethods(['where', 'first'])\n ->getMock();\n $profilesRelation->method('where')->willReturnSelf();\n $profilesRelation->method('get')->willReturn(collect([]));\n $profilesRelation->method('first')->willReturn(null);\n $config->method('profiles')->willReturn($profilesRelation);\n\n return $config;\n }\n\n private function getServiceMock(array $onlyMethods = []): MockObject&Service\n {\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $this->createMock(Client::class),\n $this->createMock(PayloadBuilder::class),\n $this->createMock(Dispatcher::class),\n $this->createMock(CountriesMap::class),\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods($onlyMethods)\n ->getMock();\n\n $serviceMock->profile = $this->createMock(Profile::class);\n\n return $serviceMock;\n }\n\n private function mockQueryBuilderAndHandler($userToSearch, $salesforceUsers): void\n {\n app()->bind(QueryBuilder::class, function () use ($userToSearch) {\n $queryBuilder = $this->createMock(QueryBuilder::class);\n $queryBuilder->expects($this->once())\n ->method('buildGetUsersQuery')\n ->with($userToSearch)\n ->willReturn('SELECT * FROM Users');\n\n return $queryBuilder;\n });\n\n $queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults($salesforceUsers, count($salesforceUsers), true, null));\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('query')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n }\n\n private function mockTeamRepository(\n Team $team,\n array $salesforceUser,\n ?User $user = null,\n bool $userSearch = true,\n array $customRules = []\n ): void {\n $teamRepository = $this->getMockForAbstractClass(TeamRepository::class, [], '', false, true, true, ['findActiveTeamMemberByEmail', 'getTeamSetting']);\n\n if ($userSearch) {\n $teamRepository->expects($this->once())\n ->method('findActiveTeamMemberByEmail')\n ->with($team, $salesforceUser['Email'])\n ->willReturn($user);\n }\n\n $teamSettings = $this->createMock(TeamSettings::class);\n $teamSettings->method('getValueType')\n ->willReturn('array');\n\n $teamSettings->method('getValue')\n ->willReturn(json_encode($customRules));\n\n $teamRepository->expects($this->once())\n ->method('getTeamSetting')\n ->with($team, 'custom_profile_validation')\n ->willReturn($teamSettings);\n\n app()->bind(TeamRepository::class, function () use ($teamRepository) {\n return $teamRepository;\n });\n }\n\n private function mockProfileRepository(User $user): void\n {\n $profileRepository = $this->createMock(ProfileRepository::class);\n $profileRepository->expects($this->once())\n ->method('updateOrCreateProfile')\n ->with(\n $user,\n [\n 'crm_configuration_id' => 1,\n 'crm_provider_id' => '456',\n ],\n [\n 'user_id' => 123,\n 'edition' => Profile::EDITION_LIGHTNING,\n 'has_external_cti' => true,\n 'crm_profile_id' => '789',\n ]\n )\n ->willReturn(new Profile());\n\n $this->app->instance(ProfileRepository::class, $profileRepository);\n }\n\n public function testBuildEnhancedNoteDecodesHtmlEntities(): void\n {\n $service = $this->getServiceMock(['createRecord']);\n\n $profile = new Profile();\n $profile->setAttribute('crm_provider_id', 'owner-123');\n $service->profile = $profile;\n\n $service->expects($this->exactly(2))\n ->method('createRecord')\n ->willReturnOnConsecutiveCalls('note-id-123', 'link-id-456');\n\n $bodyWithEntities = 'Welch's current challenges and Facebook's Club';\n\n $result = $this->invokePrivateMethod('buildEnhancedNote', $service, [\n 'Test Title',\n $bodyWithEntities,\n 'object-id-789',\n ]);\n\n $this->assertEquals('note-id-123', $result);\n }\n\n public function testBuildEnhancedNoteSanitizesWithoutQuotes(): void\n {\n $service = $this->getServiceMock(['createRecord']);\n\n $profile = new Profile();\n $profile->setAttribute('crm_provider_id', 'owner-456');\n $service->profile = $profile;\n\n $service->expects($this->exactly(2))\n ->method('createRecord')\n ->willReturnCallback(function ($type, $data) {\n if ($type === 'ContentNote') {\n $decoded = base64_decode($data['Content']);\n $this->assertStringContainsString(\"Welch's\", $decoded);\n $this->assertStringNotContainsString(''', $decoded);\n $this->assertStringNotContainsString('&#039;', $decoded);\n $this->assertStringContainsString('<script>', $decoded);\n\n return 'note-id-456';\n }\n\n return 'link-id-789';\n });\n\n $bodyWithMixedContent = \"Welch's and <script>alert('xss')</script>\";\n\n $result = $this->invokePrivateMethod('buildEnhancedNote', $service, [\n 'Test Title',\n $bodyWithMixedContent,\n 'object-id-123',\n ]);\n\n $this->assertEquals('note-id-456', $result);\n }\n\n public function testBuildEnhancedNoteConvertsLineBreaks(): void\n {\n $service = $this->getServiceMock(['createRecord']);\n\n $profile = new Profile();\n $profile->setAttribute('crm_provider_id', 'owner-789');\n $service->profile = $profile;\n\n $service->expects($this->exactly(2))\n ->method('createRecord')\n ->willReturnCallback(function ($type, $data) {\n if ($type === 'ContentNote') {\n $decoded = base64_decode($data['Content']);\n $this->assertStringContainsString('<br>', $decoded);\n $this->assertStringNotContainsString('<br />', $decoded);\n\n return 'note-id-789';\n }\n\n return 'link-id-012';\n });\n\n $bodyWithLineBreaks = \"Line 1\\nLine 2\\nLine 3\";\n\n $result = $this->invokePrivateMethod('buildEnhancedNote', $service, [\n 'Test Title',\n $bodyWithLineBreaks,\n 'object-id-456',\n ]);\n\n $this->assertEquals('note-id-789', $result);\n }\n\n public function testBuildEnhancedNoteHandlesComplexScenario(): void\n {\n $service = $this->getServiceMock(['createRecord']);\n\n $profile = new Profile();\n $profile->setAttribute('crm_provider_id', 'owner-complex');\n $service->profile = $profile;\n\n $service->expects($this->exactly(2))\n ->method('createRecord')\n ->willReturnCallback(function ($type, $data) {\n if ($type === 'ContentNote') {\n $decoded = base64_decode($data['Content']);\n\n $this->assertStringContainsString(\"Welch's\", $decoded);\n $this->assertStringContainsString(\"Facebook's Club\", $decoded);\n $this->assertStringContainsString(\"Arctics'\", $decoded);\n $this->assertStringNotContainsString(''', $decoded);\n $this->assertStringNotContainsString('&#039;', $decoded);\n $this->assertStringContainsString('<br>', $decoded);\n $this->assertStringContainsString('<', $decoded);\n $this->assertStringContainsString('>', $decoded);\n\n return 'note-complex';\n }\n\n return 'link-complex';\n });\n\n $complexBody = \"Summary:\\n---------\\nThe call focused on understanding Welch's current challenges and exploring how Arctics' Revenue Growth Management solutions could support their strategic goals.\\n\\n• John SMith discussed his role as a category advisor for Google and Facebook's Club, emphasizing the importance of market research and advising on product assortment.\\n• Madona introduced Arctics' Virtual Shoppers AI, which simulates consumer <behavior> to optimize pricing and promotional strategies.\";\n\n $result = $this->invokePrivateMethod('buildEnhancedNote', $service, [\n 'Jiminny Transcription Summary',\n $complexBody,\n 'task-id-001',\n ]);\n\n $this->assertEquals('note-complex', $result);\n }\n\n public function testSyncRemotelyDeletedObjectsWithErrorHandlingSuccess(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['syncRemotelyDeletedObjects'])\n ->getMock();\n\n // Mock team\n $team = $this->createMock(Team::class);\n $team->method('getUuid')->willReturn('team-uuid-123');\n $serviceMock->team = $team;\n\n // Expect syncRemotelyDeletedObjects to be called once and succeed\n $serviceMock->expects($this->once())\n ->method('syncRemotelyDeletedObjects')\n ->with(\\Jiminny\\Enums\\CrmObject::ACCOUNT);\n\n // Call the protected method using reflection\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');\n $method->setAccessible(true);\n\n // Should not throw any exceptions\n $method->invoke($serviceMock, \\Jiminny\\Enums\\CrmObject::ACCOUNT);\n\n $this->assertTrue(true); // Test completed successfully\n }\n\n public function testSyncRemotelyDeletedObjectsWithErrorHandlingFailure(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['syncRemotelyDeletedObjects'])\n ->getMock();\n\n // Mock team\n $team = $this->createMock(Team::class);\n $team->method('getUuid')->willReturn('team-uuid-456');\n $serviceMock->team = $team;\n\n // Mock logger to verify warning is logged\n $logger = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n\n // Use reflection to set the protected logger property\n $reflection = new \\ReflectionClass($serviceMock);\n $loggerProperty = $reflection->getProperty('logger');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($serviceMock, $logger);\n\n $exception = new \\Exception('Sync failed due to API error');\n\n // Expect syncRemotelyDeletedObjects to throw an exception\n $serviceMock->expects($this->once())\n ->method('syncRemotelyDeletedObjects')\n ->with(\\Jiminny\\Enums\\CrmObject::CONTACT)\n ->willThrowException($exception);\n\n // Expect warning to be logged with correct message and parameters\n $logger->expects($this->once())\n ->method('warning')\n ->with(\n '[Salesforce] Remotely deleted objects sync failed',\n [\n 'objectType' => 'contact',\n 'teamId' => 'team-uuid-456',\n 'reason' => 'Sync failed due to API error',\n ]\n );\n\n // Call the protected method using reflection\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');\n $method->setAccessible(true);\n\n // Should not re-throw the exception, just log it\n $method->invoke($serviceMock, \\Jiminny\\Enums\\CrmObject::CONTACT);\n\n $this->assertTrue(true); // Test completed successfully\n }\n\n public function testSyncRemotelyDeletedObjectsWithErrorHandlingWithLogParams(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['syncRemotelyDeletedObjects'])\n ->getMock();\n\n // Mock team\n $team = $this->createMock(Team::class);\n $team->method('getUuid')->willReturn('team-uuid-789');\n $serviceMock->team = $team;\n\n // Mock logger to verify warning is logged\n $logger = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n\n // Use reflection to set the protected logger property\n $loggerReflection = new \\ReflectionClass($serviceMock);\n $loggerProperty = $loggerReflection->getProperty('logger');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($serviceMock, $logger);\n\n $exception = new \\Exception('Network timeout');\n\n // Expect syncRemotelyDeletedObjects to throw an exception\n $serviceMock->expects($this->once())\n ->method('syncRemotelyDeletedObjects')\n ->with(\\Jiminny\\Enums\\CrmObject::OPPORTUNITY)\n ->willThrowException($exception);\n\n // Additional log parameters\n $logParams = [\n 'syncType' => 'full',\n 'batchSize' => 100,\n ];\n\n // Expect warning to be logged with merged parameters\n $logger->expects($this->once())\n ->method('warning')\n ->with(\n '[Salesforce] Remotely deleted objects sync failed',\n [\n 'objectType' => 'opportunity',\n 'teamId' => 'team-uuid-789',\n 'reason' => 'Network timeout',\n 'syncType' => 'full',\n 'batchSize' => 100,\n ]\n );\n\n // Call the protected method using reflection\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');\n $method->setAccessible(true);\n\n // Should not re-throw the exception, just log it\n $method->invoke($serviceMock, \\Jiminny\\Enums\\CrmObject::OPPORTUNITY, $logParams);\n\n $this->assertTrue(true); // Test completed successfully\n }\n\n /**\n * @dataProvider crmObjectProvider\n */\n public function testSyncRemotelyDeletedObjectsWithErrorHandlingDifferentCrmObjects(\\Jiminny\\Enums\\CrmObject $crmObject): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['syncRemotelyDeletedObjects'])\n ->getMock();\n\n // Mock team\n $team = $this->createMock(Team::class);\n $team->method('getUuid')->willReturn('team-uuid-test');\n $serviceMock->team = $team;\n\n // Mock logger to verify warning is logged\n $logger = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n\n // Use reflection to set the protected logger property\n $loggerReflectionClass = new \\ReflectionClass($serviceMock);\n $loggerProperty = $loggerReflectionClass->getProperty('logger');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($serviceMock, $logger);\n\n $exception = new \\Exception('Test error');\n\n // Expect syncRemotelyDeletedObjects to throw an exception\n $serviceMock->expects($this->once())\n ->method('syncRemotelyDeletedObjects')\n ->with($crmObject)\n ->willThrowException($exception);\n\n // Expect warning to be logged with correct entity type\n $logger->expects($this->once())\n ->method('warning')\n ->with(\n '[Salesforce] Remotely deleted objects sync failed',\n [\n 'objectType' => $crmObject->value,\n 'teamId' => 'team-uuid-test',\n 'reason' => 'Test error',\n ]\n );\n\n // Call the protected method using reflection\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');\n $method->setAccessible(true);\n\n $method->invoke($serviceMock, $crmObject);\n\n $this->assertTrue(true); // Test completed successfully\n }\n\n public function testHandleObjectDeletionWithDeletedEntity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['deleteCrmObject'])\n ->getMock();\n\n $entity = $this->createMock(\\Jiminny\\Models\\Account::class);\n $crmData = ['IsDeleted' => true];\n\n $serviceMock->expects($this->once())\n ->method('deleteCrmObject')\n ->with($entity);\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('handleObjectDeletion');\n $method->setAccessible(true);\n\n $method->invoke($serviceMock, $entity, $crmData);\n }\n\n public function testHandleObjectDeletionWithNonDeletedEntity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['deleteCrmObject'])\n ->getMock();\n\n $entity = $this->createMock(\\Jiminny\\Models\\Contact::class);\n $crmData = ['IsDeleted' => false];\n\n $serviceMock->expects($this->never())\n ->method('deleteCrmObject');\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('handleObjectDeletion');\n $method->setAccessible(true);\n\n $method->invoke($serviceMock, $entity, $crmData);\n }\n\n public function testDeleteCrmObjectWithValidEntity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['dispatchDeleteCrmObjectJob'])\n ->getMock();\n\n $entity = $this->createMock(\\Jiminny\\Models\\Lead::class);\n $entity->expects($this->once())->method('delete');\n\n $serviceMock->expects($this->once())\n ->method('dispatchDeleteCrmObjectJob')\n ->with($entity);\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('deleteCrmObject');\n $method->setAccessible(true);\n\n $method->invoke($serviceMock, $entity);\n }\n\n public function testDeleteCrmObjectWithNullEntity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['dispatchDeleteCrmObjectJob'])\n ->getMock();\n\n $serviceMock->expects($this->never())\n ->method('dispatchDeleteCrmObjectJob');\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('deleteCrmObject');\n $method->setAccessible(true);\n\n $method->invoke($serviceMock, null);\n }\n\n public function testDispatchDeleteCrmObjectJobWithNullEntity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($service);\n $method = $reflection->getMethod('dispatchDeleteCrmObjectJob');\n $method->setAccessible(true);\n\n // Should return early without dispatching - no exception expected\n $method->invoke($service, null);\n\n $this->assertTrue(true); // Test completed successfully\n }\n\n public function testDispatchDeleteCrmObjectJobWithUnsupportedEntity(): void\n {\n $this->expectException(\\TypeError::class);\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n $unsupportedEntity = $this->createMock(\\stdClass::class);\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($service);\n $method = $reflection->getMethod('dispatchDeleteCrmObjectJob');\n\n // This will throw TypeError due to union type constraint\n $method->invoke($service, $unsupportedEntity);\n }\n\n public function testHandleEntityDeletionByProviderIdMethodExists(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n // Test that the method exists and is accessible via reflection\n $reflection = new \\ReflectionClass($service);\n $method = $reflection->getMethod('handleEntityDeletionByProviderId');\n $method->setAccessible(true);\n\n // Verify method exists and has correct parameters\n $this->assertTrue($method->isProtected());\n $this->assertEquals(2, $method->getNumberOfParameters());\n\n $parameters = $method->getParameters();\n $this->assertEquals('targetEntity', $parameters[0]->getName());\n $this->assertEquals('crmData', $parameters[1]->getName());\n }\n\n public function testSyncRemotelyDeletedObjectsWithNoResults(): void\n {\n // Create a real service instance to avoid mock property issues\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n // Mock queryHandler to throw NoResultsException\n $queryHandler = $this->createMock(QueryHandler::class);\n $queryHandler->expects($this->once())\n ->method('queryDeleted')\n ->with('Opportunity')\n ->willThrowException(new NoResultsException('No results'));\n\n // Set the queryHandler using reflection on the real service\n $reflection = new \\ReflectionClass($service);\n $queryHandlerProperty = $reflection->getProperty('queryHandler');\n $queryHandlerProperty->setAccessible(true);\n $queryHandlerProperty->setValue($service, $queryHandler);\n\n $result = self::invokePrivateMethod('syncRemotelyDeletedObjects', $service, [CrmObject::OPPORTUNITY]);\n\n $this->assertFalse($result);\n }\n\n public function testSyncRemotelyDeletedObjectsWithEmptyResults(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n // Mock queryHandler to return empty results\n $queryResult = $this->createMock(QueryResults::class);\n $queryResult->method('getResults')->willReturn([]);\n\n $queryHandler = $this->createMock(QueryHandler::class);\n $queryHandler->expects($this->once())\n ->method('queryDeleted')\n ->with('Opportunity')\n ->willReturn($queryResult);\n\n // Set the queryHandler using reflection on the real service\n $reflection = new \\ReflectionClass($service);\n $queryHandlerProperty = $reflection->getProperty('queryHandler');\n $queryHandlerProperty->setAccessible(true);\n $queryHandlerProperty->setValue($service, $queryHandler);\n\n $result = self::invokePrivateMethod('syncRemotelyDeletedObjects', $service, [CrmObject::OPPORTUNITY]);\n\n $this->assertFalse($result);\n }\n\n public function testSyncRemotelyDeletedObjectsWithUnsupportedCrmObject(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n // Mock queryHandler to return some deleted objects so we reach the match statement\n $deletedObjects = [\n ['id' => 'task1'],\n ['id' => 'task2'],\n ];\n $queryResult = $this->createMock(QueryResults::class);\n $queryResult->method('getResults')->willReturn($deletedObjects);\n\n $queryHandler = $this->createMock(QueryHandler::class);\n $queryHandler->expects($this->once())\n ->method('queryDeleted')\n ->with('Task') // ucfirst('task') = 'Task'\n ->willReturn($queryResult);\n\n self::setPrivateProperty($service, 'queryHandler', $queryHandler);\n\n $this->expectException(InvalidArgumentException::class);\n $this->expectExceptionMessage('Unsupported CrmObject: task');\n\n self::invokePrivateMethod('syncRemotelyDeletedObjects', $service, [CrmObject::TASK]);\n }\n\n public static function crmObjectProvider(): array\n {\n return [\n 'Account' => [CrmObject::ACCOUNT],\n 'Contact' => [CrmObject::CONTACT],\n 'Lead' => [CrmObject::LEAD],\n 'Opportunity' => [CrmObject::OPPORTUNITY],\n ];\n }\n\n public function testVerifyTaskExistsReturnsTrue(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-123', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-123');\n $activity->method('getId')->willReturn(456);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-123', ['Id', 'IsDeleted'])\n ->willReturn(['Id' => 'task-123', 'IsDeleted' => false]);\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertTrue($result);\n }\n\n public function testVerifyTaskExistsReturnsTrueForEvent(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:event-123', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('event-123');\n $activity->method('getId')->willReturn(456);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_EVENT);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Event', 'event-123', ['Id', 'IsDeleted'])\n ->willReturn(['Id' => 'event-123', 'IsDeleted' => false]);\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertTrue($result);\n }\n\n public function testVerifyTaskExistsReturnsFalseWhenDeleted(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-456', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-456');\n $activity->method('getId')->willReturn(789);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-456', ['Id', 'IsDeleted'])\n ->willReturn(['Id' => 'task-456', 'IsDeleted' => true]);\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertFalse($result);\n }\n\n public function testVerifyTaskExistsReturnsFalseWhenNotFound(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-999', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-999');\n $activity->method('getId')->willReturn(999);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-999', ['Id', 'IsDeleted'])\n ->willThrowException(new \\Jiminny\\Exceptions\\HttpNotFoundException('Task not found'));\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertFalse($result);\n }\n\n public function testVerifyTaskExistsReturnsFalseWhenNoPlaybook(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-no-playbook', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-no-playbook');\n $activity->method('getId')->willReturn(111);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn(null);\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertFalse($result);\n }\n\n public function testVerifyTaskExistsThrowsExceptionForTransientErrors(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-error', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-error');\n $activity->method('getId')->willReturn(888);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-error', ['Id', 'IsDeleted'])\n ->willThrowException(new \\RuntimeException('Network timeout'));\n\n $this->expectException(\\RuntimeException::class);\n $this->expectExceptionMessage('Network timeout');\n\n $service->verifyTaskExists($activity);\n }\n\n public function testVerifyTaskExistsCachesResults(): void\n {\n $cachedValue = null;\n Cache::shouldReceive('remember')\n ->twice()\n ->with('crm_task_exists:123:task-cached', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(function ($key, $ttl, $callback) use (&$cachedValue) {\n if ($cachedValue === null) {\n $cachedValue = $callback();\n }\n\n return $cachedValue;\n });\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-cached');\n $activity->method('getId')->willReturn(555);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-cached', ['Id', 'IsDeleted'])\n ->willReturn(['Id' => 'task-cached', 'IsDeleted' => false]);\n\n $result1 = $service->verifyTaskExists($activity);\n $result2 = $service->verifyTaskExists($activity);\n\n $this->assertTrue($result1);\n $this->assertTrue($result2);\n }\n\n public function testVerifyTaskExistsReturnsFalseForHttpBadRequestException(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-400', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-400');\n $activity->method('getId')->willReturn(400);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-400', ['Id', 'IsDeleted'])\n ->willThrowException(new \\Jiminny\\Exceptions\\HttpBadRequestException('Bad request'));\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertFalse($result);\n }\n\n public function testImportOpportunitySkipsWhenNoProfileAndNoAccount(): void\n {\n $crmData = [\n 'Id' => 'SF-NO-USER-1',\n 'Name' => 'Test Opportunity',\n 'OwnerId' => 'owner-no-profile',\n // No AccountId\n ];\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(\\Illuminate\\Events\\Dispatcher::class); // ← ADD THIS\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n\n $service = new Service(\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService\n );\n\n $config = $this->createMock(Configuration::class);\n\n // Mock profiles relation returning null (no profile found)\n $profilesRelation = \\Mockery::mock(\\Illuminate\\Database\\Eloquent\\Relations\\HasMany::class);\n $profilesRelation->shouldReceive('where')->with('crm_provider_id', 'owner-no-profile')->andReturnSelf();\n $profilesRelation->shouldReceive('first')->andReturn(null);\n\n $config->expects($this->once())\n ->method('profiles')\n ->willReturn($profilesRelation);\n\n $team = $this->createMock(Team::class);\n $team->method('getId')->willReturn(1);\n\n $logger = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n $logger->expects($this->once())\n ->method('error')\n ->with(\n '[Salesforce] | Skip import, no user_id found',\n ['id' => 'SF-NO-USER-1']\n );\n\n $reflection = new \\ReflectionClass($service);\n\n $configProperty = $reflection->getProperty('config');\n $configProperty->setAccessible(true);\n $configProperty->setValue($service, $config);\n\n $teamProperty = $reflection->getProperty('team');\n $teamProperty->setAccessible(true);\n $teamProperty->setValue($service, $team);\n\n $loggerProperty = $reflection->getProperty('logger');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($service, $logger);\n\n // Initialize profile property to avoid \"must not be accessed before initialization\" error\n $profileProperty = $reflection->getProperty('profile');\n $profileProperty->setAccessible(true);\n $profileProperty->setValue($service, null);\n\n $result = self::invokePrivateMethod('importOpportunity', $service, [$crmData]);\n\n $this->assertNull($result);\n }\n\n public function testImportOpportunitySkipsWhenProfileNotFound(): void\n {\n $crmData = [\n 'Id' => 'SF-NO-USER-2',\n 'Name' => 'Test Opportunity',\n 'OwnerId' => 'owner-not-found',\n // No AccountId - avoid complex account processing\n ];\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $eventDispatcher = $this->createMock(\\Illuminate\\Events\\Dispatcher::class); // ← ADD THIS\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n\n\n $service = new Service(\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService\n );\n\n $config = $this->createMock(Configuration::class);\n\n // Mock profiles relation returning null (no profile found)\n $profilesRelation = \\Mockery::mock(\\Illuminate\\Database\\Eloquent\\Relations\\HasMany::class);\n $profilesRelation->shouldReceive('where')->with('crm_provider_id', 'owner-not-found')->andReturnSelf();\n $profilesRelation->shouldReceive('first')->andReturn(null);\n\n $config->expects($this->once())\n ->method('profiles')\n ->willReturn($profilesRelation);\n\n $team = $this->createMock(Team::class);\n $team->method('getId')->willReturn(1);\n\n $logger = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n $logger->expects($this->once())\n ->method('error')\n ->with(\n '[Salesforce] | Skip import, no user_id found',\n ['id' => 'SF-NO-USER-2']\n );\n\n $reflection = new \\ReflectionClass($service);\n\n $configProperty = $reflection->getProperty('config');\n $configProperty->setAccessible(true);\n $configProperty->setValue($service, $config);\n\n $teamProperty = $reflection->getProperty('team');\n $teamProperty->setAccessible(true);\n $teamProperty->setValue($service, $team);\n\n $loggerProperty = $reflection->getProperty('logger');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($service, $logger);\n\n // Initialize profile property\n $profileProperty = $reflection->getProperty('profile');\n $profileProperty->setAccessible(true);\n $profileProperty->setValue($service, null);\n\n $result = self::invokePrivateMethod('importOpportunity', $service, [$crmData]);\n\n $this->assertNull($result);\n }\n}\n\nclass HasManyExtended extends HasMany\n{\n public function where()\n {\n }\n\n public function first()\n {\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"A","depth":4,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":5,"bounds":{"left":0.27027926,"top":1.0,"width":0.0039893617,"height":0.0},"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Select All","depth":4,"bounds":{"left":0.42652926,"top":0.14365523,"width":0.09009308,"height":0.0207502},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"320","depth":6,"bounds":{"left":0.5169548,"top":0.16520351,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"320","depth":7,"bounds":{"left":0.5169548,"top":0.16520351,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"5772","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"5772","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"46ea38f7-c940-395d-9c79-0329d30e9ab7","depth":6,"bounds":{"left":0.5169548,"top":0.1867518,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"46ea38f7-c940-395d-9c79-0329d30e9ab7","depth":7,"bounds":{"left":0.5169548,"top":0.1867518,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"2024-11-26 09:21:15","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"2024-11-26 09:21:15","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"401","depth":6,"bounds":{"left":0.5169548,"top":0.20830008,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"401","depth":7,"bounds":{"left":0.5169548,"top":0.20830008,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"5772","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"5772","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"<null>","depth":6,"bounds":{"left":0.5169548,"top":0.22984837,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"<null>","depth":7,"bounds":{"left":0.5169548,"top":0.22984837,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"5772","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"5772","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"salesforce","depth":6,"bounds":{"left":0.5169548,"top":0.25139666,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"salesforce","depth":7,"bounds":{"left":0.5169548,"top":0.25139666,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"2024-11-26 09:21:15","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"2024-11-26 09:21:15","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"enterprise","depth":6,"bounds":{"left":0.5169548,"top":0.27294493,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"enterprise","depth":7,"bounds":{"left":0.5169548,"top":0.27294493,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"2024-11-26 09:21:15","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"2024-11-26 09:21:15","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"SWE38","depth":6,"bounds":{"left":0.5169548,"top":0.29449323,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"SWE38","depth":7,"bounds":{"left":0.5169548,"top":0.29449323,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"2024-11-26 09:21:15","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"2024-11-26 09:21:15","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"0","depth":6,"bounds":{"left":0.5169548,"top":0.3160415,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"0","depth":7,"bounds":{"left":0.5169548,"top":0.3160415,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"5772","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"5772","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"50.0","depth":6,"bounds":{"left":0.5169548,"top":0.33758977,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"50.0","depth":7,"bounds":{"left":0.5169548,"top":0.33758977,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"2024-11-26 09:21:15","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"2024-11-26 09:21:15","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"01801","depth":6,"bounds":{"left":0.5169548,"top":0.35913807,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"01801","depth":7,"bounds":{"left":0.5169548,"top":0.35913807,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"2024-11-26 09:21:15","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"2024-11-26 09:21:15","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"1","depth":6,"bounds":{"left":0.5169548,"top":0.38068634,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"1","depth":7,"bounds":{"left":0.5169548,"top":0.38068634,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"5772","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"5772","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"1","depth":6,"bounds":{"left":0.5169548,"top":0.40223464,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"1","depth":7,"bounds":{"left":0.5169548,"top":0.40223464,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"5772","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"5772","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"1","depth":6,"bounds":{"left":0.5169548,"top":0.4237829,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"1","depth":7,"bounds":{"left":0.5169548,"top":0.4237829,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"5772","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"5772","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"00D20000000MdbgEAC","depth":6,"bounds":{"left":0.5169548,"top":0.44533122,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"00D20000000MdbgEAC","depth":7,"bounds":{"left":0.5169548,"top":0.44533122,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"2024-11-26 09:21:15","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"2024-11-26 09:21:15","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"https://bipsolutions.my.salesforce.com","depth":6,"bounds":{"left":0.5169548,"top":0.4668795,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"https://bipsolutions.my.salesforce.com","depth":7,"bounds":{"left":0.5169548,"top":0.4668795,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"2024-11-26 09:21:15","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"2024-11-26 09:21:15","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"2026-03-19 08:19:24","depth":6,"bounds":{"left":0.5169548,"top":0.4884278,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"2026-03-19 08:19:24","depth":7,"bounds":{"left":0.5169548,"top":0.4884278,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"2024-11-26 09:21:15","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"2024-11-26 09:21:15","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"2026-05-22 09:48:21","depth":6,"bounds":{"left":0.5169548,"top":0.509976,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"2026-05-22 09:48:21","depth":7,"bounds":{"left":0.5169548,"top":0.509976,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"2024-11-26 09:21:15","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"2024-11-26 09:21:15","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"2026-05-22 09:48:31","depth":6,"bounds":{"left":0.5169548,"top":0.53152436,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"2026-05-22 09:48:31","depth":7,"bounds":{"left":0.5169548,"top":0.53152436,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"2024-11-26 09:21:15","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"2024-11-26 09:21:15","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"2026-05-22 09:48:43","depth":6,"bounds":{"left":0.5169548,"top":0.55307263,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"2026-05-22 09:48:43","depth":7,"bounds":{"left":0.5169548,"top":0.55307263,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"2024-11-26 09:21:15","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"2024-11-26 09:21:15","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"2026-05-22 09:48:29","depth":6,"bounds":{"left":0.5169548,"top":0.5746209,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"2026-05-22 09:48:29","depth":7,"bounds":{"left":0.5169548,"top":0.5746209,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"2024-11-26 09:21:15","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"2024-11-26 09:21:15","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"2026-05-22 09:48:40","depth":6,"bounds":{"left":0.5169548,"top":0.5961692,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"2026-05-22 09:48:40","depth":7,"bounds":{"left":0.5169548,"top":0.5961692,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"2024-11-26 09:21:15","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"2024-11-26 09:21:15","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"<null>","depth":6,"bounds":{"left":0.5169548,"top":0.6177175,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"<null>","depth":7,"bounds":{"left":0.5169548,"top":0.6177175,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"2024-11-26 09:21:15","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"2024-11-26 09:21:15","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"<null>","depth":6,"bounds":{"left":0.5169548,"top":0.6392658,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"<null>","depth":7,"bounds":{"left":0.5169548,"top":0.6392658,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"2024-11-26 09:21:15","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"2024-11-26 09:21:15","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"{\"opportunity_sync_period\":\"3m\",\"opportunity_sync_strategies\":[\"lastModified\"]}","depth":6,"bounds":{"left":0.5169548,"top":0.66081405,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"{\"opportunity_sync_period\":\"3m\",\"opportunity_sync_strategies\":[\"lastModified\"]}","depth":7,"bounds":{"left":0.5169548,"top":0.66081405,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"{\"opportunity_sync_period\":\"3m\",\"opp","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"{\"opportunity_sync_period\":\"3m\",\"opp","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"open-recently-updated","depth":6,"bounds":{"left":0.5169548,"top":0.6823623,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"open-recently-updated","depth":7,"bounds":{"left":0.5169548,"top":0.6823623,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"2024-11-26 09:21:15","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"2024-11-26 09:21:15","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
7732332105235147404
|
-6520523286572803295
|
idle
|
accessibility
|
NULL
|
Project: faVsco.js, menu
JY-20963-fix-import-on-de Project: faVsco.js, menu
JY-20963-fix-import-on-deleted-entity, menu
Start Listening for PHP Debug Connections
ServiceTest
Rerun 'PHPUnit: ServiceTest'
Debug 'ServiceTest'
Stop 'ServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Built-in Preview
Chrome
Firefox
Safari
Sync Changes
Hide This Notification
Code changed:
Hide
4
32
168
1
28
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Tests\Unit\Services\Crm\Salesforce;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Events\Dispatcher;
use Illuminate\Support\Facades\Cache;
use Jiminny\Component\Country\CountriesMap;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Enums\CrmObject;
use Jiminny\Exceptions\InvalidArgumentException;
use Jiminny\Exceptions\NoResultsException;
use Jiminny\Integrations\PlaybookResolver;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Playbook;
use Jiminny\Models\Team;
use Jiminny\Models\TeamSettings;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\ContactRoleRepository;
use Jiminny\Repositories\Crm\FieldDataRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\Salesforce\Client;
use Jiminny\Services\Crm\Salesforce\PayloadBuilder;
use Jiminny\Services\Crm\Salesforce\QueryBuilder;
use Jiminny\Services\Crm\Salesforce\QueryHandler;
use Jiminny\Services\Crm\Salesforce\QueryIterator;
use Jiminny\Services\Crm\Salesforce\QueryResults;
use Jiminny\Services\Crm\Salesforce\Service;
use PHPUnit\Framework\MockObject\MockObject;
use Tests\TestCase;
use Tests\Unit\Traits\TestPrivateMethod;
class ServiceTest extends TestCase
{
use TestPrivateMethod;
public function testFetchAndAssociateRelatedActivity(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$payloadBuilder->method('addCustomLogicFieldsPayload')
->willReturnCallback(function ($activity, $payload) {
return $payload;
});
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods(['fetchRelatedActivity', 'getPlaybook', 'getPlaybookCategory', 'updateRecord'])
->getMock();
$serviceMock->expects($this->once())
->method('fetchRelatedActivity')
->willReturn([
'Id' => 'testId',
'Type' => null,
'OwnerId' => 'testerUser',
'Description' => 'Test description',
]);
$user = $this->createMock(User::class);
$team = $this->createMock(Team::class);
$user->method('getAttribute')->with('team')->willReturn($team);
$playbook = $this->createMock(Playbook::class);
$playbook->method('getActivityField')->willReturn(null);
$playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_EVENT);
$serviceMock->expects($this->once())
->method('getPlaybook')
->with($user)
->willReturn($playbook);
$serviceMock->expects($this->never())
->method('getPlaybookCategory');
$serviceMock->expects($this->never())
->method('updateRecord');
$fieldDataRepository = $this->createMock(FieldDataRepository::class);
$fieldDataRepository->method('getActivityFieldData')->willReturn(collect([]));
app()->instance(FieldDataRepository::class, $fieldDataRepository);
$config = $this->createMock(Configuration::class);
$profilesRelation = $this->getMockBuilder(\Illuminate\Database\Eloquent\Relations\HasMany::class)
->disableOriginalConstructor()
->onlyMethods(['get'])
->addMethods(['where'])
->getMock();
$profilesRelation->method('where')->willReturnSelf();
$profilesRelation->method('get')->willReturn(collect([]));
$config->method('profiles')->willReturn($profilesRelation);
$serviceMock->config = $config;
$serviceMock->profile = null;
$actualStartTime = \Carbon\Carbon::now();
$activity = $this->getMockBuilder(Activity::class)
->disableOriginalConstructor()
->onlyMethods(['update', 'hasProspect'])
->getMock();
$activity->method('update')->willReturn(true);
$activity->method('hasProspect')->willReturn(true);
$activity->type = Activity::TYPE_CONFERENCE;
$activity->provider = Activity::PROVIDER_TWILIO;
$activity->lead_id = 1;
$activity->user_id = 0;
$activity->id_string = 'test-activity-id';
$activity->user = $user;
$activity->actual_start_time = $actualStartTime;
$activity->uuid = 'c53d8320-f556-4cee-a2f8-5f232f454ca4';
app()->bind(PlaybookResolver::class, function () use ($user) {
$playbook = $this->createMock(Playbook::class);
$playbookResolver = $this->createMock(PlaybookResolver::class);
$playbookResolver->expects($this->once())
->method('resolvePlaybookByUser')
->with($user)
->willReturn($playbook);
return $playbookResolver;
});
$data = $serviceMock->fetchAndAssociateRelatedActivity($activity);
$this->assertInstanceOf(Activity::class, $data);
$this->assertEquals(Activity::TYPE_CONFERENCE, $data->getType());
$this->assertEquals($actualStartTime->getTimestamp(), $data->getActualStartTime()->getTimestamp());
}
public function testFetchAndAssociateRelatedActivitySkipsForTaskBasedPlaybook(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods(['fetchRelatedActivity', 'getPlaybook'])
->getMock();
$user = $this->createMock(User::class);
$playbook = $this->createMock(Playbook::class);
$playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);
$playbook->method('getId')->willReturn(123);
$serviceMock->expects($this->once())
->method('getPlaybook')
->with($user)
->willReturn($playbook);
$serviceMock->expects($this->never())
->method('fetchRelatedActivity');
$activity = $this->getMockBuilder(Activity::class)
->disableOriginalConstructor()
->onlyMethods(['hasProspect', 'getUuid'])
->getMock();
$activity->method('hasProspect')->willReturn(true);
$activity->method('getUuid')->willReturn('c53d8320-f556-4cee-a2f8-5f232f454ca4');
$activity->type = Activity::TYPE_CONFERENCE;
$activity->actual_start_time = \Carbon\Carbon::now();
$activity->user = $user;
$result = $serviceMock->fetchAndAssociateRelatedActivity($activity);
$this->assertNull($result);
}
public function testFetchAndAssociateRelatedActivityReturnsNullForNonConference(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$serviceMock = new Service(
client: $client,
payloadBuilder: $payloadBuilder,
eventDispatcher: $eventDispatcher,
countriesMap: $countriesMap,
prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)
);
$activity = $this->getMockBuilder(Activity::class)
->disableOriginalConstructor()
->getMock();
$activity->type = Activity::TYPE_SOFTPHONE;
$result = $serviceMock->fetchAndAssociateRelatedActivity($activity);
$this->assertNull($result);
}
public function testFetchAndAssociateRelatedActivityReturnsNullWhenNoStartTime(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$serviceMock = new Service(
client: $client,
payloadBuilder: $payloadBuilder,
eventDispatcher: $eventDispatcher,
countriesMap: $countriesMap,
prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)
);
$activity = $this->getMockBuilder(Activity::class)
->disableOriginalConstructor()
->getMock();
$activity->type = Activity::TYPE_CONFERENCE;
$activity->actual_start_time = null;
$activity->scheduled_start_time = null;
$result = $serviceMock->fetchAndAssociateRelatedActivity($activity);
$this->assertNull($result);
}
public function testFetchAndAssociateRelatedActivityReturnsNullWhenNoProspect(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods(['getPlaybook'])
->getMock();
$serviceMock->expects($this->never())
->method('getPlaybook');
$activity = $this->getMockBuilder(Activity::class)
->disableOriginalConstructor()
->onlyMethods(['hasProspect', 'getUuid'])
->getMock();
$activity->method('hasProspect')->willReturn(false);
$activity->method('getUuid')->willReturn('c53d8320-f556-4cee-a2f8-5f232f454ca4');
$activity->type = Activity::TYPE_CONFERENCE;
$activity->actual_start_time = \Carbon\Carbon::now();
$result = $serviceMock->fetchAndAssociateRelatedActivity($activity);
$this->assertNull($result);
}
public function testMatchExactlyByEmail(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods([])
->getMock();
$profile = new Profile();
$profile->setAttribute('id', bin2hex(random_bytes(8)));
$serviceMock->profile = $profile;
$team = $this->createMock(Team::class);
$serviceMock->team = $team;
$data = $serviceMock->matchExactlyByEmail(bin2hex(random_bytes(8)) . '[EMAIL]');
$this->assertEquals(null, $data);
}
public function testMatchDomainFromEmail(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$queryIterator = $this->createMock(QueryIterator::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$config = $this->createMock(Configuration::class);
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('search')
->willReturn($queryIterator);
return $handler;
});
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods(['convertCrmData'])
->getMock();
$profile = new Profile();
$profile->account_fields = 'Field1, Field2, Field3';
$serviceMock->profile = $profile;
$serviceMock->expects($this->once())
->method('convertCrmData')
->willReturn(['test']);
$this->app->bind(QueryBuilder::class, function () {
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->expects($this->once())
->method('buildMatchByDomainQuery')
->with('[EMAIL]')
->willReturn('FIND {[EMAIL]} IN ALL FIELDS RETURNING Account(Id)');
return $queryBuilder;
});
$team = $this->createMock(Team::class);
$serviceMock->team = $team;
$serviceMock->config = $config;
$data = $serviceMock->matchByDomain('[EMAIL]');
$this->assertEquals(['test'], $data);
}
public function testBuildTaskSearchFields(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$service = new Service(
client: $client,
payloadBuilder: $payloadBuilder,
eventDispatcher: $eventDispatcher,
countriesMap: $countriesMap,
prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)
);
$fields = $service->buildTaskSearchFields();
$expectedFields = ['Id', 'WhoId', 'WhatId', 'AccountId'];
$this->assertEquals($expectedFields, $fields);
}
public function testMapCrmObjects(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$service = new Service(
client: $client,
payloadBuilder: $payloadBuilder,
eventDispatcher: $eventDispatcher,
countriesMap: $countriesMap,
prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class),
);
$sampleTask = [
'WhoId' => '003sampleWhoId',
'AccountId' => 'sampleAccountId',
'WhatId' => 'sampleWhatId',
];
$activityData = $service->mapCrmObjects($sampleTask);
$expectedActivityData = [
'contact' => '003sampleWhoId',
'account' => 'sampleAccountId',
'opportunity' => 'sampleWhatId',
];
$this->assertEquals($expectedActivityData, $activityData);
}
public function testGetInstalledAppVersion(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$queryIterator = $this->createMock(QueryIterator::class);
$queryIterator->expects($this->any())
->method('current')->willReturn([
'SubscriberPackageVersion' => [
'MajorVersion' => '1',
'MinorVersion' => '0',
'PatchVersion' => '1',
'BuildNumber' => '0',
],
]);
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('metadata')
->willReturn($queryIterator);
return $handler;
});
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods(array_diff(get_class_methods(Service::class), ['getInstalledAppVersion']))
->getMock();
$version = $serviceMock->getInstalledAppVersion();
$this->assertEquals('1010', $version);
}
public function testSyncProfiles(): void
{
$userToSearch = null;
$team = $this->createMock(Team::class);
$config = $this->createMockedConfiguration();
$config->expects($this->once())
->method('getId')
->willReturn(1);
$salesforceUser = [
'Email' => '[EMAIL]',
'UserPreferencesLightningExperiencePreferred' => true,
'CallCenterId' => '123',
'Id' => '456',
'ProfileId' => '789',
];
app()->bind(QueryBuilder::class, function () use ($userToSearch) {
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->expects($this->once())
->method('buildGetUsersQuery')
->with($userToSearch)
->willReturn('SELECT * FROM Users');
return $queryBuilder;
});
$queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('query')
->with('SELECT * FROM Users')
->willReturn($queryIterator);
return $handler;
});
$user = $this->createMock(User::class);
$user->expects($this->once())
->method('getId')
->willReturn(123);
$this->mockTeamRepository($team, $salesforceUser, $user);
$profileRepository = $this->createMock(ProfileRepository::class);
$profileRepository->expects($this->once())
->method('updateOrCreateProfile')
->with(
$user,
[
'crm_configuration_id' => 1,
'crm_provider_id' => '456',
],
[
'user_id' => 123,
'edition' => Profile::EDITION_LIGHTNING,
'has_external_cti' => true,
'crm_profile_id' => '789',
]
)
->willReturn(new Profile());
$this->app->instance(ProfileRepository::class, $profileRepository);
$serviceMock = $this->getServiceMock();
$serviceMock->team = $team;
$serviceMock->config = $config;
$result = $serviceMock->syncProfiles($userToSearch);
$this->assertNull($result);
}
public function testSyncProfilesEmailIsNull(): void
{
$userToSearch = $this->createMock(User::class);
$salesforceUser = [
'Email' => null,
];
app()->bind(QueryBuilder::class, function () {
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->expects($this->once())
->method('buildGetUsersQuery')
->with(null)
->willReturn('SELECT * FROM Users');
return $queryBuilder;
});
$queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->exactly(2))
->method('query')
->willReturn($queryIterator);
return $handler;
});
$team = $this->createMock(Team::class);
$user = $this->createMock(User::class);
$this->mockTeamRepository($team, $salesforceUser, $user, false);
$config = $this->createMock(Configuration::class);
$serviceMock = $this->getServiceMock();
$serviceMock->team = $team;
$serviceMock->config = $config;
$profile = $serviceMock->syncProfiles(null);
$this->assertNull($profile);
}
public function testSyncProfilesUserToSearchMatchesCurrentUser(): void
{
$userToSearch = $this->createMock(User::class);
$userToSearch->expects($this->once())
->method('getId')
->willReturn(123);
$team = $this->createMock(Team::class);
$config = $this->createMock(Configuration::class);
$config->expects($this->once())
->method('getId')
->willReturn(1);
$salesforceUser = [
'Email' => '[EMAIL]',
'UserPreferencesLightningExperiencePreferred' => true,
'CallCenterId' => '123',
'Id' => '456',
'ProfileId' => '789',
];
app()->bind(QueryBuilder::class, function () use ($userToSearch) {
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->expects($this->once())
->method('buildGetUsersQuery')
->with($userToSearch)
->willReturn('SELECT * FROM Users');
return $queryBuilder;
});
$queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('query')
->with('SELECT * FROM Users')
->willReturn($queryIterator);
return $handler;
});
$user = $this->createMock(User::class);
$user->expects($this->exactly(2))
->method('getId')
->willReturn(123);
$this->mockTeamRepository($team, $salesforceUser, $user);
$profileRepository = $this->createMock(ProfileRepository::class);
$profileRepository->expects($this->once())
->method('updateOrCreateProfile')
->with(
$user,
[
'crm_configuration_id' => 1,
'crm_provider_id' => '456',
],
[
'user_id' => 123,
'edition' => Profile::EDITION_LIGHTNING,
'has_external_cti' => true,
'crm_profile_id' => '789',
]
)
->willReturn(new Profile());
$this->app->instance(ProfileRepository::class, $profileRepository);
$serviceMock = $this->getServiceMock();
$serviceMock->team = $team;
$serviceMock->config = $config;
$profile = $serviceMock->syncProfiles($userToSearch);
$this->assertInstanceOf(Profile::class, $profile);
}
public function testSyncProfilesWithCustomValidation(): void
{
$userToSearch = null;
$team = $this->createMock(Team::class);
$config = $this->createMockedConfiguration();
$config->expects($this->atLeastOnce()) // Changed from once() to atLeastOnce()
->method('getId')
->willReturn(1);
$salesforceUser = [
'Email' => '[EMAIL]',
'UserPreferencesLightningExperiencePreferred' => true,
'CallCenterId' => '123',
'Id' => '456',
'ProfileId' => '789',
'CustomField' => 'CustomValue',
];
$customRules = [
['field' => 'CustomField', 'value' => 'CustomValue'],
];
$this->mockQueryBuilderAndHandler($userToSearch, [$salesforceUser]);
$user = $this->createMock(User::class);
$user->expects($this->once())
->method('getId')
->willReturn(123);
$this->mockTeamRepository($team, $salesforceUser, $user, true, $customRules);
$this->mockProfileRepository($user);
$serviceMock = $this->getServiceMock();
$serviceMock->team = $team;
$serviceMock->config = $config;
$profile = $serviceMock->syncProfiles($userToSearch);
$this->assertNull($profile);
}
public function testSyncProfilesWithCustomValidationFailing(): void
{
$userToSearch = null;
$team = $this->createMock(Team::class);
$config = $this->createMockedConfiguration();
$salesforceUser = [
'Email' => '[EMAIL]',
'UserPreferencesLightningExperiencePreferred' => true,
'CallCenterId' => '123',
'Id' => '456',
'ProfileId' => '789',
'CustomField' => 'WrongValue',
];
$customRules = [
['field' => 'CustomField', 'value' => 'CustomValue'],
];
$this->mockQueryBuilderAndHandler($userToSearch, [$salesforceUser]);
$teamRepository = $this->getMockForAbstractClass(TeamRepository::class, [], '', false, true, true, ['findActiveTeamMemberByEmail', 'getTeamSetting']);
$teamSettings = $this->createMock(TeamSettings::class);
$teamSettings->method('getValueType')
->willReturn('array');
$teamSettings->method('getValue')
->willReturn(json_encode($customRules));
$teamRepository->expects($this->once())
->method('getTeamSetting')
->with($team, 'custom_profile_validation')
->willReturn($teamSettings);
app()->bind(TeamRepository::class, function () use ($teamRepository) {
return $teamRepository;
});
$profileRepository = $this->createMock(ProfileRepository::class);
$profileRepository->expects($this->never())
->method('updateOrCreateProfile');
$this->app->instance(ProfileRepository::class, $profileRepository);
$serviceMock = $this->getServiceMock();
$serviceMock->team = $team;
$serviceMock->config = $config;
$result = $serviceMock->syncProfiles($userToSearch);
$this->assertNull($result);
}
public function testGetContactRolesFromCrm(): void
{
$contactRoles = [
[
'Id' => '1',
'ContactId' => 'Contact1',
'OpportunityId' => 'Opportunity1',
'Opportunity' => ['OwnerId' => 'Owner1'],
'IsPrimary' => true,
'Role' => 'Decision Maker',
],
];
$expectedResponse = [
[
'id' => '1',
'contactId' => 'Contact1',
'opportunityId' => 'Opportunity1',
'ownerId' => 'Owner1',
'isPrimary' => true,
'role' => 'Decision Maker',
],
];
$this->bindQueryIterator($contactRoles);
$serviceMock = $this->getServiceMock();
$data = $serviceMock->getContactRolesFromCrm(now()->subDay());
$this->assertEquals($expectedResponse, $data);
}
public function testGetContactRolesFromCrmNoResult(): void
{
$this->bindQueryIterator([]);
$serviceMock = $this->getServiceMock();
$data = $serviceMock->getContactRolesFromCrm(now()->subDay());
$this->assertEquals([], $data);
}
public function testSyncContactRoles(): void
{
$contactRoles = [
[
'id' => '1',
'contactId' => 'Contact1',
'opportunityId' => 'Opportunity1',
'ownerId' => 'Owner1',
'isPrimary' => true,
'role' => 'Decision Maker',
],
];
app()->bind(ContactRoleRepository::class, function () {
$contactRoleRepository = $this->createMock(ContactRoleRepository::class);
$contactRoleRepository->expects($this->once())
->method('saveContactRoles');
return $contactRoleRepository;
});
$serviceMock = $this->getServiceMock([
'getContactRolesFromCrm',
'syncRemotelyDeletedContactRoles',
'syncContact',
'syncOpportunity',
]);
$config = $this->createMock(Configuration::class);
$hasMany = $this->createMock(HasManyExtended::class);
$hasMany->expects($this->exactly(2))
->method('where')
->willReturn($hasMany);
$hasMany->expects($this->exactly(2))
->method('first')
->willReturn(
$this->createMock(Contact::class),
$this->createMock(Opportunity::class)
);
$config->expects($this->once())
->method('contacts')
->willReturn($hasMany);
$config->expects($this->once())
->method('opportunities')
->willReturn($hasMany);
$serviceMock->config = $config;
$serviceMock->expects($this->once())
->method('getContactRolesFromCrm')
->willReturn($contactRoles);
$serviceMock->expects($this->once())
->method('syncRemotelyDeletedContactRoles');
$data = $serviceMock->syncContactRoles(now()->subDay());
$this->assertEquals(1, $data);
}
public function testSyncRemotelyDeletedContactRoles(): void
{
$contactRoles = [
[
'id' => '1',
'crm_provider_id' => '1',
],
];
app()->bind(QueryHandler::class, function () use ($contactRoles) {
$queryResults = new QueryResults($contactRoles, 1, true, null);
$handler = $this->createMock(QueryHandler::class);
$handler->method('queryDeleted')
->willReturn($queryResults);
return $handler;
});
app()->bind(ContactRoleRepository::class, function () {
$contactRoleRepository = $this->createMock(ContactRoleRepository::class);
$contactRoleRepository->expects($this->once())
->method('deleteContactRoles');
return $contactRoleRepository;
});
$serviceMock = $this->getServiceMock();
$serviceMock->team = $this->createMock(Team::class);
$data = $this->invokePrivateMethod('syncRemotelyDeletedContactRoles', $serviceMock, []);
$this->assertTrue($data);
}
private function bindQueryIterator(array $queryResult): void
{
/** @var Client $client */
$client = $this->createMock(Client::class);
$queryIterator = new QueryIterator(
$client,
new QueryResults($queryResult, 1, true, null)
);
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('query')
->willReturn($queryIterator);
return $handler;
});
}
public static function getOpportunitySortOrderDataProvider(): array
{
return [
'all open recently updated' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED, ['LastModifiedDate DESC', true]],
'all open recently created' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED, ['CreatedDate DESC', true]],
'all open oldest created' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED, ['CreatedDate ASC', true]],
'all recently updated' => [Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED, ['LastModifiedDate DESC', false]],
'default' => ['unknown', ['LastModifiedDate DESC', true]],
];
}
private function createMockedConfiguration(): Configuration
{
$config = $this->createMock(Configuration::class);
$profilesRelation = $this->getMockBuilder(\Illuminate\Database\Eloquent\Relations\HasMany::class)
->disableOriginalConstructor()
->onlyMethods(['get'])
->addMethods(['where', 'first'])
->getMock();
$profilesRelation->method('where')->willReturnSelf();
$profilesRelation->method('get')->willReturn(collect([]));
$profilesRelation->method('first')->willReturn(null);
$config->method('profiles')->willReturn($profilesRelation);
return $config;
}
private function getServiceMock(array $onlyMethods = []): MockObject&Service
{
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$this->createMock(Client::class),
$this->createMock(PayloadBuilder::class),
$this->createMock(Dispatcher::class),
$this->createMock(CountriesMap::class),
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods($onlyMethods)
->getMock();
$serviceMock->profile = $this->createMock(Profile::class);
return $serviceMock;
}
private function mockQueryBuilderAndHandler($userToSearch, $salesforceUsers): void
{
app()->bind(QueryBuilder::class, function () use ($userToSearch) {
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->expects($this->once())
->method('buildGetUsersQuery')
->with($userToSearch)
->willReturn('SELECT * FROM Users');
return $queryBuilder;
});
$queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults($salesforceUsers, count($salesforceUsers), true, null));
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('query')
->willReturn($queryIterator);
return $handler;
});
}
private function mockTeamRepository(
Team $team,
array $salesforceUser,
?User $user = null,
bool $userSearch = true,
array $customRules = []
): void {
$teamRepository = $this->getMockForAbstractClass(TeamRepository::class, [], '', false, true, true, ['findActiveTeamMemberByEmail', 'getTeamSetting']);
if ($userSearch) {
$teamRepository->expects($this->once())
->method('findActiveTeamMemberByEmail')
->with($team, $salesforceUser['Email'])
->willReturn($user);
}
$teamSettings = $this->createMock(TeamSettings::class);
$teamSettings->method('getValueType')
->willReturn('array');
$teamSettings->method('getValue')
->willReturn(json_encode($customRules));
$teamRepository->expects($this->once())
->method('getTeamSetting')
->with($team, 'custom_profile_validation')
->willReturn($teamSettings);
app()->bind(TeamRepository::class, function () use ($teamRepository) {
return $teamRepository;
});
}
private function mockProfileRepository(User $user): void
{
$profileRepository = $this->createMock(ProfileRepository::class);
$profileRepository->expects($this->once())
->method('updateOrCreateProfile')
->with(
$user,
[
'crm_configuration_id' => 1,
'crm_provider_id' => '456',
],
[
'user_id' => 123,
'edition' => Profile::EDITION_LIGHTNING,
'has_external_cti' => true,
'crm_profile_id' => '789',
]
)
->willReturn(new Profile());
$this->app->instance(ProfileRepository::class, $profileRepository);
}
public function testBuildEnhancedNoteDecodesHtmlEntities(): void
{
$service = $this->getServiceMock(['createRecord']);
$profile = new Profile();
$profile->setAttribute('crm_provider_id', 'owner-123');
$service->profile = $profile;
$service->expects($this->exactly(2))
->method('createRecord')
->willReturnOnConsecutiveCalls('note-id-123', 'link-id-456');
$bodyWithEntities = 'Welch's current challenges and Facebook's Club';
$result = $this->invokePrivateMethod('buildEnhancedNote', $service, [
'Test Title',
$bodyWithEntities,
'object-id-789',
]);
$this->assertEquals('note-id-123', $result);
}
public function testBuildEnhancedNoteSanitizesWithoutQuotes(): void
{
$service = $this->getServiceMock(['createRecord']);
$profile = new Profile();
$profile->setAttribute('crm_provider_id', 'owner-456');
$service->profile = $profile;
$service->expects($this->exactly(2))
->method('createRecord')
->willReturnCallback(function ($type, $data) {
if ($type === 'ContentNote') {
$decoded = base64_decode($data['Content']);
$this->assertStringContainsString("Welch's", $decoded);
$this->assertStringNotContainsString(''', $decoded);
$this->assertStringNotContainsString('&#039;', $decoded);
$this->assertStringContainsString('<script>', $decoded);
return 'note-id-456';
}
return 'link-id-789';
});
$bodyWithMixedContent = "Welch's and <script>alert('xss')</script>";
$result = $this->invokePrivateMethod('buildEnhancedNote', $service, [
'Test Title',
$bodyWithMixedContent,
'object-id-123',
]);
$this->assertEquals('note-id-456', $result);
}
public function testBuildEnhancedNoteConvertsLineBreaks(): void
{
$service = $this->getServiceMock(['createRecord']);
$profile = new Profile();
$profile->setAttribute('crm_provider_id', 'owner-789');
$service->profile = $profile;
$service->expects($this->exactly(2))
->method('createRecord')
->willReturnCallback(function ($type, $data) {
if ($type === 'ContentNote') {
$decoded = base64_decode($data['Content']);
$this->assertStringContainsString('<br>', $decoded);
$this->assertStringNotContainsString('<br />', $decoded);
return 'note-id-789';
}
return 'link-id-012';
});
$bodyWithLineBreaks = "Line 1\nLine 2\nLine 3";
$result = $this->invokePrivateMethod('buildEnhancedNote', $service, [
'Test Title',
$bodyWithLineBreaks,
'object-id-456',
]);
$this->assertEquals('note-id-789', $result);
}
public function testBuildEnhancedNoteHandlesComplexScenario(): void
{
$service = $this->getServiceMock(['createRecord']);
$profile = new Profile();
$profile->setAttribute('crm_provider_id', 'owner-complex');
$service->profile = $profile;
$service->expects($this->exactly(2))
->method('createRecord')
->willReturnCallback(function ($type, $data) {
if ($type === 'ContentNote') {
$decoded = base64_decode($data['Content']);
$this->assertStringContainsString("Welch's", $decoded);
$this->assertStringContainsString("Facebook's Club", $decoded);
$this->assertStringContainsString("Arctics'", $decoded);
$this->assertStringNotContainsString(''', $decoded);
$this->assertStringNotContainsString('&#039;', $decoded);
$this->assertStringContainsString('<br>', $decoded);
$this->assertStringContainsString('<', $decoded);
$this->assertStringContainsString('>', $decoded);
return 'note-complex';
}
return 'link-complex';
});
$complexBody = "Summary:\n---------\nThe call focused on understanding Welch's current challenges and exploring how Arctics' Revenue Growth Management solutions could support their strategic goals.\n\n• John SMith discussed his role as a category advisor for Google and Facebook's Club, emphasizing the importance of market research and advising on product assortment.\n• Madona introduced Arctics' Virtual Shoppers AI, which simulates consumer <behavior> to optimize pricing and promotional strategies.";
$result = $this->invokePrivateMethod('buildEnhancedNote', $service, [
'Jiminny Transcription Summary',
$complexBody,
'task-id-001',
]);
$this->assertEquals('note-complex', $result);
}
public function testSyncRemotelyDeletedObjectsWithErrorHandlingSuccess(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$prospectPhotoPathService,
])
->onlyMethods(['syncRemotelyDeletedObjects'])
->getMock();
// Mock team
$team = $this->createMock(Team::class);
$team->method('getUuid')->willReturn('team-uuid-123');
$serviceMock->team = $team;
// Expect syncRemotelyDeletedObjects to be called once and succeed
$serviceMock->expects($this->once())
->method('syncRemotelyDeletedObjects')
->with(\Jiminny\Enums\CrmObject::ACCOUNT);
// Call the protected method using reflection
$reflection = new \ReflectionClass($serviceMock);
$method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');
$method->setAccessible(true);
// Should not throw any exceptions
$method->invoke($serviceMock, \Jiminny\Enums\CrmObject::ACCOUNT);
$this->assertTrue(true); // Test completed successfully
}
public function testSyncRemotelyDeletedObjectsWithErrorHandlingFailure(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$prospectPhotoPathService,
])
->onlyMethods(['syncRemotelyDeletedObjects'])
->getMock();
// Mock team
$team = $this->createMock(Team::class);
$team->method('getUuid')->willReturn('team-uuid-456');
$serviceMock->team = $team;
// Mock logger to verify warning is logged
$logger = $this->createMock(\Psr\Log\LoggerInterface::class);
// Use reflection to set the protected logger property
$reflection = new \ReflectionClass($serviceMock);
$loggerProperty = $reflection->getProperty('logger');
$loggerProperty->setAccessible(true);
$loggerProperty->setValue($serviceMock, $logger);
$exception = new \Exception('Sync failed due to API error');
// Expect syncRemotelyDeletedObjects to throw an exception
$serviceMock->expects($this->once())
->method('syncRemotelyDeletedObjects')
->with(\Jiminny\Enums\CrmObject::CONTACT)
->willThrowException($exception);
// Expect warning to be logged with correct message and parameters
$logger->expects($this->once())
->method('warning')
->with(
'[Salesforce] Remotely deleted objects sync failed',
[
'objectType' => 'contact',
'teamId' => 'team-uuid-456',
'reason' => 'Sync failed due to API error',
]
);
// Call the protected method using reflection
$reflection = new \ReflectionClass($serviceMock);
$method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');
$method->setAccessible(true);
// Should not re-throw the exception, just log it
$method->invoke($serviceMock, \Jiminny\Enums\CrmObject::CONTACT);
$this->assertTrue(true); // Test completed successfully
}
public function testSyncRemotelyDeletedObjectsWithErrorHandlingWithLogParams(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$prospectPhotoPathService,
])
->onlyMethods(['syncRemotelyDeletedObjects'])
->getMock();
// Mock team
$team = $this->createMock(Team::class);
$team->method('getUuid')->willReturn('team-uuid-789');
$serviceMock->team = $team;
// Mock logger to verify warning is logged
$logger = $this->createMock(\Psr\Log\LoggerInterface::class);
// Use reflection to set the protected logger property
$loggerReflection = new \ReflectionClass($serviceMock);
$loggerProperty = $loggerReflection->getProperty('logger');
$loggerProperty->setAccessible(true);
$loggerProperty->setValue($serviceMock, $logger);
$exception = new \Exception('Network timeout');
// Expect syncRemotelyDeletedObjects to throw an exception
$serviceMock->expects($this->once())
->method('syncRemotelyDeletedObjects')
->with(\Jiminny\Enums\CrmObject::OPPORTUNITY)
->willThrowException($exception);
// Additional log parameters
$logParams = [
'syncType' => 'full',
'batchSize' => 100,
];
// Expect warning to be logged with merged parameters
$logger->expects($this->once())
->method('warning')
->with(
'[Salesforce] Remotely deleted objects sync failed',
[
'objectType' => 'opportunity',
'teamId' => 'team-uuid-789',
'reason' => 'Network timeout',
'syncType' => 'full',
'batchSize' => 100,
]
);
// Call the protected method using reflection
$reflection = new \ReflectionClass($serviceMock);
$method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');
...
|
70626
|
NULL
|
NULL
|
NULL
|
|
70629
|
2536
|
7
|
2026-05-22T10:25:25.322511+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779445525322_m1.jpg...
|
PhpStorm
|
faVsco.js – ServiceTest.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20963-fix-import-on-de Project: faVsco.js, menu
JY-20963-fix-import-on-deleted-entity, menu
Start Listening for PHP Debug Connections
ServiceTest
Rerun 'PHPUnit: ServiceTest'
Debug 'ServiceTest'
Stop 'ServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Built-in Preview
Chrome
Firefox
Safari
Sync Changes
Hide This Notification
Code changed:
Hide
4
32
168
1
28
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Tests\Unit\Services\Crm\Salesforce;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Events\Dispatcher;
use Illuminate\Support\Facades\Cache;
use Jiminny\Component\Country\CountriesMap;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Enums\CrmObject;
use Jiminny\Exceptions\InvalidArgumentException;
use Jiminny\Exceptions\NoResultsException;
use Jiminny\Integrations\PlaybookResolver;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Playbook;
use Jiminny\Models\Team;
use Jiminny\Models\TeamSettings;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\ContactRoleRepository;
use Jiminny\Repositories\Crm\FieldDataRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\Salesforce\Client;
use Jiminny\Services\Crm\Salesforce\PayloadBuilder;
use Jiminny\Services\Crm\Salesforce\QueryBuilder;
use Jiminny\Services\Crm\Salesforce\QueryHandler;
use Jiminny\Services\Crm\Salesforce\QueryIterator;
use Jiminny\Services\Crm\Salesforce\QueryResults;
use Jiminny\Services\Crm\Salesforce\Service;
use PHPUnit\Framework\MockObject\MockObject;
use Tests\TestCase;
use Tests\Unit\Traits\TestPrivateMethod;
class ServiceTest extends TestCase
{
use TestPrivateMethod;
public function testFetchAndAssociateRelatedActivity(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$payloadBuilder->method('addCustomLogicFieldsPayload')
->willReturnCallback(function ($activity, $payload) {
return $payload;
});
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods(['fetchRelatedActivity', 'getPlaybook', 'getPlaybookCategory', 'updateRecord'])
->getMock();
$serviceMock->expects($this->once())
->method('fetchRelatedActivity')
->willReturn([
'Id' => 'testId',
'Type' => null,
'OwnerId' => 'testerUser',
'Description' => 'Test description',
]);
$user = $this->createMock(User::class);
$team = $this->createMock(Team::class);
$user->method('getAttribute')->with('team')->willReturn($team);
$playbook = $this->createMock(Playbook::class);
$playbook->method('getActivityField')->willReturn(null);
$playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_EVENT);
$serviceMock->expects($this->once())
->method('getPlaybook')
->with($user)
->willReturn($playbook);
$serviceMock->expects($this->never())
->method('getPlaybookCategory');
$serviceMock->expects($this->never())
->method('updateRecord');
$fieldDataRepository = $this->createMock(FieldDataRepository::class);
$fieldDataRepository->method('getActivityFieldData')->willReturn(collect([]));
app()->instance(FieldDataRepository::class, $fieldDataRepository);
$config = $this->createMock(Configuration::class);
$profilesRelation = $this->getMockBuilder(\Illuminate\Database\Eloquent\Relations\HasMany::class)
->disableOriginalConstructor()
->onlyMethods(['get'])
->addMethods(['where'])
->getMock();
$profilesRelation->method('where')->willReturnSelf();
$profilesRelation->method('get')->willReturn(collect([]));
$config->method('profiles')->willReturn($profilesRelation);
$serviceMock->config = $config;
$serviceMock->profile = null;
$actualStartTime = \Carbon\Carbon::now();
$activity = $this->getMockBuilder(Activity::class)
->disableOriginalConstructor()
->onlyMethods(['update', 'hasProspect'])
->getMock();
$activity->method('update')->willReturn(true);
$activity->method('hasProspect')->willReturn(true);
$activity->type = Activity::TYPE_CONFERENCE;
$activity->provider = Activity::PROVIDER_TWILIO;
$activity->lead_id = 1;
$activity->user_id = 0;
$activity->id_string = 'test-activity-id';
$activity->user = $user;
$activity->actual_start_time = $actualStartTime;
$activity->uuid = 'c53d8320-f556-4cee-a2f8-5f232f454ca4';
app()->bind(PlaybookResolver::class, function () use ($user) {
$playbook = $this->createMock(Playbook::class);
$playbookResolver = $this->createMock(PlaybookResolver::class);
$playbookResolver->expects($this->once())
->method('resolvePlaybookByUser')
->with($user)
->willReturn($playbook);
return $playbookResolver;
});
$data = $serviceMock->fetchAndAssociateRelatedActivity($activity);
$this->assertInstanceOf(Activity::class, $data);
$this->assertEquals(Activity::TYPE_CONFERENCE, $data->getType());
$this->assertEquals($actualStartTime->getTimestamp(), $data->getActualStartTime()->getTimestamp());
}
public function testFetchAndAssociateRelatedActivitySkipsForTaskBasedPlaybook(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods(['fetchRelatedActivity', 'getPlaybook'])
->getMock();
$user = $this->createMock(User::class);
$playbook = $this->createMock(Playbook::class);
$playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);
$playbook->method('getId')->willReturn(123);
$serviceMock->expects($this->once())
->method('getPlaybook')
->with($user)
->willReturn($playbook);
$serviceMock->expects($this->never())
->method('fetchRelatedActivity');
$activity = $this->getMockBuilder(Activity::class)
->disableOriginalConstructor()
->onlyMethods(['hasProspect', 'getUuid'])
->getMock();
$activity->method('hasProspect')->willReturn(true);
$activity->method('getUuid')->willReturn('c53d8320-f556-4cee-a2f8-5f232f454ca4');
$activity->type = Activity::TYPE_CONFERENCE;
$activity->actual_start_time = \Carbon\Carbon::now();
$activity->user = $user;
$result = $serviceMock->fetchAndAssociateRelatedActivity($activity);
$this->assertNull($result);
}
public function testFetchAndAssociateRelatedActivityReturnsNullForNonConference(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$serviceMock = new Service(
client: $client,
payloadBuilder: $payloadBuilder,
eventDispatcher: $eventDispatcher,
countriesMap: $countriesMap,
prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)
);
$activity = $this->getMockBuilder(Activity::class)
->disableOriginalConstructor()
->getMock();
$activity->type = Activity::TYPE_SOFTPHONE;
$result = $serviceMock->fetchAndAssociateRelatedActivity($activity);
$this->assertNull($result);
}
public function testFetchAndAssociateRelatedActivityReturnsNullWhenNoStartTime(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$serviceMock = new Service(
client: $client,
payloadBuilder: $payloadBuilder,
eventDispatcher: $eventDispatcher,
countriesMap: $countriesMap,
prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)
);
$activity = $this->getMockBuilder(Activity::class)
->disableOriginalConstructor()
->getMock();
$activity->type = Activity::TYPE_CONFERENCE;
$activity->actual_start_time = null;
$activity->scheduled_start_time = null;
$result = $serviceMock->fetchAndAssociateRelatedActivity($activity);
$this->assertNull($result);
}
public function testFetchAndAssociateRelatedActivityReturnsNullWhenNoProspect(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods(['getPlaybook'])
->getMock();
$serviceMock->expects($this->never())
->method('getPlaybook');
$activity = $this->getMockBuilder(Activity::class)
->disableOriginalConstructor()
->onlyMethods(['hasProspect', 'getUuid'])
->getMock();
$activity->method('hasProspect')->willReturn(false);
$activity->method('getUuid')->willReturn('c53d8320-f556-4cee-a2f8-5f232f454ca4');
$activity->type = Activity::TYPE_CONFERENCE;
$activity->actual_start_time = \Carbon\Carbon::now();
$result = $serviceMock->fetchAndAssociateRelatedActivity($activity);
$this->assertNull($result);
}
public function testMatchExactlyByEmail(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods([])
->getMock();
$profile = new Profile();
$profile->setAttribute('id', bin2hex(random_bytes(8)));
$serviceMock->profile = $profile;
$team = $this->createMock(Team::class);
$serviceMock->team = $team;
$data = $serviceMock->matchExactlyByEmail(bin2hex(random_bytes(8)) . '[EMAIL]');
$this->assertEquals(null, $data);
}
public function testMatchDomainFromEmail(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$queryIterator = $this->createMock(QueryIterator::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$config = $this->createMock(Configuration::class);
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('search')
->willReturn($queryIterator);
return $handler;
});
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods(['convertCrmData'])
->getMock();
$profile = new Profile();
$profile->account_fields = 'Field1, Field2, Field3';
$serviceMock->profile = $profile;
$serviceMock->expects($this->once())
->method('convertCrmData')
->willReturn(['test']);
$this->app->bind(QueryBuilder::class, function () {
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->expects($this->once())
->method('buildMatchByDomainQuery')
->with('[EMAIL]')
->willReturn('FIND {[EMAIL]} IN ALL FIELDS RETURNING Account(Id)');
return $queryBuilder;
});
$team = $this->createMock(Team::class);
$serviceMock->team = $team;
$serviceMock->config = $config;
$data = $serviceMock->matchByDomain('[EMAIL]');
$this->assertEquals(['test'], $data);
}
public function testBuildTaskSearchFields(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$service = new Service(
client: $client,
payloadBuilder: $payloadBuilder,
eventDispatcher: $eventDispatcher,
countriesMap: $countriesMap,
prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)
);
$fields = $service->buildTaskSearchFields();
$expectedFields = ['Id', 'WhoId', 'WhatId', 'AccountId'];
$this->assertEquals($expectedFields, $fields);
}
public function testMapCrmObjects(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$service = new Service(
client: $client,
payloadBuilder: $payloadBuilder,
eventDispatcher: $eventDispatcher,
countriesMap: $countriesMap,
prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class),
);
$sampleTask = [
'WhoId' => '003sampleWhoId',
'AccountId' => 'sampleAccountId',
'WhatId' => 'sampleWhatId',
];
$activityData = $service->mapCrmObjects($sampleTask);
$expectedActivityData = [
'contact' => '003sampleWhoId',
'account' => 'sampleAccountId',
'opportunity' => 'sampleWhatId',
];
$this->assertEquals($expectedActivityData, $activityData);
}
public function testGetInstalledAppVersion(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$queryIterator = $this->createMock(QueryIterator::class);
$queryIterator->expects($this->any())
->method('current')->willReturn([
'SubscriberPackageVersion' => [
'MajorVersion' => '1',
'MinorVersion' => '0',
'PatchVersion' => '1',
'BuildNumber' => '0',
],
]);
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('metadata')
->willReturn($queryIterator);
return $handler;
});
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods(array_diff(get_class_methods(Service::class), ['getInstalledAppVersion']))
->getMock();
$version = $serviceMock->getInstalledAppVersion();
$this->assertEquals('1010', $version);
}
public function testSyncProfiles(): void
{
$userToSearch = null;
$team = $this->createMock(Team::class);
$config = $this->createMockedConfiguration();
$config->expects($this->once())
->method('getId')
->willReturn(1);
$salesforceUser = [
'Email' => '[EMAIL]',
'UserPreferencesLightningExperiencePreferred' => true,
'CallCenterId' => '123',
'Id' => '456',
'ProfileId' => '789',
];
app()->bind(QueryBuilder::class, function () use ($userToSearch) {
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->expects($this->once())
->method('buildGetUsersQuery')
->with($userToSearch)
->willReturn('SELECT * FROM Users');
return $queryBuilder;
});
$queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('query')
->with('SELECT * FROM Users')
->willReturn($queryIterator);
return $handler;
});
$user = $this->createMock(User::class);
$user->expects($this->once())
->method('getId')
->willReturn(123);
$this->mockTeamRepository($team, $salesforceUser, $user);
$profileRepository = $this->createMock(ProfileRepository::class);
$profileRepository->expects($this->once())
->method('updateOrCreateProfile')
->with(
$user,
[
'crm_configuration_id' => 1,
'crm_provider_id' => '456',
],
[
'user_id' => 123,
'edition' => Profile::EDITION_LIGHTNING,
'has_external_cti' => true,
'crm_profile_id' => '789',
]
)
->willReturn(new Profile());
$this->app->instance(ProfileRepository::class, $profileRepository);
$serviceMock = $this->getServiceMock();
$serviceMock->team = $team;
$serviceMock->config = $config;
$result = $serviceMock->syncProfiles($userToSearch);
$this->assertNull($result);
}
public function testSyncProfilesEmailIsNull(): void
{
$userToSearch = $this->createMock(User::class);
$salesforceUser = [
'Email' => null,
];
app()->bind(QueryBuilder::class, function () {
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->expects($this->once())
->method('buildGetUsersQuery')
->with(null)
->willReturn('SELECT * FROM Users');
return $queryBuilder;
});
$queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->exactly(2))
->method('query')
->willReturn($queryIterator);
return $handler;
});
$team = $this->createMock(Team::class);
$user = $this->createMock(User::class);
$this->mockTeamRepository($team, $salesforceUser, $user, false);
$config = $this->createMock(Configuration::class);
$serviceMock = $this->getServiceMock();
$serviceMock->team = $team;
$serviceMock->config = $config;
$profile = $serviceMock->syncProfiles(null);
$this->assertNull($profile);
}
public function testSyncProfilesUserToSearchMatchesCurrentUser(): void
{
$userToSearch = $this->createMock(User::class);
$userToSearch->expects($this->once())
->method('getId')
->willReturn(123);
$team = $this->createMock(Team::class);
$config = $this->createMock(Configuration::class);
$config->expects($this->once())
->method('getId')
->willReturn(1);
$salesforceUser = [
'Email' => '[EMAIL]',
'UserPreferencesLightningExperiencePreferred' => true,
'CallCenterId' => '123',
'Id' => '456',
'ProfileId' => '789',
];
app()->bind(QueryBuilder::class, function () use ($userToSearch) {
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->expects($this->once())
->method('buildGetUsersQuery')
->with($userToSearch)
->willReturn('SELECT * FROM Users');
return $queryBuilder;
});
$queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('query')
->with('SELECT * FROM Users')
->willReturn($queryIterator);
return $handler;
});
$user = $this->createMock(User::class);
$user->expects($this->exactly(2))
->method('getId')
->willReturn(123);
$this->mockTeamRepository($team, $salesforceUser, $user);
$profileRepository = $this->createMock(ProfileRepository::class);
$profileRepository->expects($this->once())
->method('updateOrCreateProfile')
->with(
$user,
[
'crm_configuration_id' => 1,
'crm_provider_id' => '456',
],
[
'user_id' => 123,
'edition' => Profile::EDITION_LIGHTNING,
'has_external_cti' => true,
'crm_profile_id' => '789',
]
)
->willReturn(new Profile());
$this->app->instance(ProfileRepository::class, $profileRepository);
$serviceMock = $this->getServiceMock();
$serviceMock->team = $team;
$serviceMock->config = $config;
$profile = $serviceMock->syncProfiles($userToSearch);
$this->assertInstanceOf(Profile::class, $profile);
}
public function testSyncProfilesWithCustomValidation(): void
{
$userToSearch = null;
$team = $this->createMock(Team::class);
$config = $this->createMockedConfiguration();
$config->expects($this->atLeastOnce()) // Changed from once() to atLeastOnce()
->method('getId')
->willReturn(1);
$salesforceUser = [
'Email' => '[EMAIL]',
'UserPreferencesLightningExperiencePreferred' => true,
'CallCenterId' => '123',
'Id' => '456',
'ProfileId' => '789',
'CustomField' => 'CustomValue',
];
$customRules = [
['field' => 'CustomField', 'value' => 'CustomValue'],
];
$this->mockQueryBuilderAndHandler($userToSearch, [$salesforceUser]);
$user = $this->createMock(User::class);
$user->expects($this->once())
->method('getId')
->willReturn(123);
$this->mockTeamRepository($team, $salesforceUser, $user, true, $customRules);
$this->mockProfileRepository($user);
$serviceMock = $this->getServiceMock();
$serviceMock->team = $team;
$serviceMock->config = $config;
$profile = $serviceMock->syncProfiles($userToSearch);
$this->assertNull($profile);
}
public function testSyncProfilesWithCustomValidationFailing(): void
{
$userToSearch = null;
$team = $this->createMock(Team::class);
$config = $this->createMockedConfiguration();
$salesforceUser = [
'Email' => '[EMAIL]',
'UserPreferencesLightningExperiencePreferred' => true,
'CallCenterId' => '123',
'Id' => '456',
'ProfileId' => '789',
'CustomField' => 'WrongValue',
];
$customRules = [
['field' => 'CustomField', 'value' => 'CustomValue'],
];
$this->mockQueryBuilderAndHandler($userToSearch, [$salesforceUser]);
$teamRepository = $this->getMockForAbstractClass(TeamRepository::class, [], '', false, true, true, ['findActiveTeamMemberByEmail', 'getTeamSetting']);
$teamSettings = $this->createMock(TeamSettings::class);
$teamSettings->method('getValueType')
->willReturn('array');
$teamSettings->method('getValue')
->willReturn(json_encode($customRules));
$teamRepository->expects($this->once())
->method('getTeamSetting')
->with($team, 'custom_profile_validation')
->willReturn($teamSettings);
app()->bind(TeamRepository::class, function () use ($teamRepository) {
return $teamRepository;
});
$profileRepository = $this->createMock(ProfileRepository::class);
$profileRepository->expects($this->never())
->method('updateOrCreateProfile');
$this->app->instance(ProfileRepository::class, $profileRepository);
$serviceMock = $this->getServiceMock();
$serviceMock->team = $team;
$serviceMock->config = $config;
$result = $serviceMock->syncProfiles($userToSearch);
$this->assertNull($result);
}
public function testGetContactRolesFromCrm(): void
{
$contactRoles = [
[
'Id' => '1',
'ContactId' => 'Contact1',
'OpportunityId' => 'Opportunity1',
'Opportunity' => ['OwnerId' => 'Owner1'],
'IsPrimary' => true,
'Role' => 'Decision Maker',
],
];
$expectedResponse = [
[
'id' => '1',
'contactId' => 'Contact1',
'opportunityId' => 'Opportunity1',
'ownerId' => 'Owner1',
'isPrimary' => true,
'role' => 'Decision Maker',
],
];
$this->bindQueryIterator($contactRoles);
$serviceMock = $this->getServiceMock();
$data = $serviceMock->getContactRolesFromCrm(now()->subDay());
$this->assertEquals($expectedResponse, $data);
}
public function testGetContactRolesFromCrmNoResult(): void
{
$this->bindQueryIterator([]);
$serviceMock = $this->getServiceMock();
$data = $serviceMock->getContactRolesFromCrm(now()->subDay());
$this->assertEquals([], $data);
}
public function testSyncContactRoles(): void
{
$contactRoles = [
[
'id' => '1',
'contactId' => 'Contact1',
'opportunityId' => 'Opportunity1',
'ownerId' => 'Owner1',
'isPrimary' => true,
'role' => 'Decision Maker',
],
];
app()->bind(ContactRoleRepository::class, function () {
$contactRoleRepository = $this->createMock(ContactRoleRepository::class);
$contactRoleRepository->expects($this->once())
->method('saveContactRoles');
return $contactRoleRepository;
});
$serviceMock = $this->getServiceMock([
'getContactRolesFromCrm',
'syncRemotelyDeletedContactRoles',
'syncContact',
'syncOpportunity',
]);
$config = $this->createMock(Configuration::class);
$hasMany = $this->createMock(HasManyExtended::class);
$hasMany->expects($this->exactly(2))
->method('where')
->willReturn($hasMany);
$hasMany->expects($this->exactly(2))
->method('first')
->willReturn(
$this->createMock(Contact::class),
$this->createMock(Opportunity::class)
);
$config->expects($this->once())
->method('contacts')
->willReturn($hasMany);
$config->expects($this->once())
->method('opportunities')
->willReturn($hasMany);
$serviceMock->config = $config;
$serviceMock->expects($this->once())
->method('getContactRolesFromCrm')
->willReturn($contactRoles);
$serviceMock->expects($this->once())
->method('syncRemotelyDeletedContactRoles');
$data = $serviceMock->syncContactRoles(now()->subDay());
$this->assertEquals(1, $data);
}
public function testSyncRemotelyDeletedContactRoles(): void
{
$contactRoles = [
[
'id' => '1',
'crm_provider_id' => '1',
],
];
app()->bind(QueryHandler::class, function () use ($contactRoles) {
$queryResults = new QueryResults($contactRoles, 1, true, null);
$handler = $this->createMock(QueryHandler::class);
$handler->method('queryDeleted')
->willReturn($queryResults);
return $handler;
});
app()->bind(ContactRoleRepository::class, function () {
$contactRoleRepository = $this->createMock(ContactRoleRepository::class);
$contactRoleRepository->expects($this->once())
->method('deleteContactRoles');
return $contactRoleRepository;
});
$serviceMock = $this->getServiceMock();
$serviceMock->team = $this->createMock(Team::class);
$data = $this->invokePrivateMethod('syncRemotelyDeletedContactRoles', $serviceMock, []);
$this->assertTrue($data);
}
private function bindQueryIterator(array $queryResult): void
{
/** @var Client $client */
$client = $this->createMock(Client::class);
$queryIterator = new QueryIterator(
$client,
new QueryResults($queryResult, 1, true, null)
);
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('query')
->willReturn($queryIterator);
return $handler;
});
}
public static function getOpportunitySortOrderDataProvider(): array
{
return [
'all open recently updated' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED, ['LastModifiedDate DESC', true]],
'all open recently created' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED, ['CreatedDate DESC', true]],
'all open oldest created' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED, ['CreatedDate ASC', true]],
'all recently updated' => [Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED, ['LastModifiedDate DESC', false]],
'default' => ['unknown', ['LastModifiedDate DESC', true]],
];
}
private function createMockedConfiguration(): Configuration
{
$config = $this->createMock(Configuration::class);
$profilesRelation = $this->getMockBuilder(\Illuminate\Database\Eloquent\Relations\HasMany::class)
->disableOriginalConstructor()
->onlyMethods(['get'])
->addMethods(['where', 'first'])
->getMock();
$profilesRelation->method('where')->willReturnSelf();
$profilesRelation->method('get')->willReturn(collect([]));
$profilesRelation->method('first')->willReturn(null);
$config->method('profiles')->willReturn($profilesRelation);
return $config;
}
private function getServiceMock(array $onlyMethods = []): MockObject&Service
{
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$this->createMock(Client::class),
$this->createMock(PayloadBuilder::class),
$this->createMock(Dispatcher::class),
$this->createMock(CountriesMap::class),
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods($onlyMethods)
->getMock();
$serviceMock->profile = $this->createMock(Profile::class);
return $serviceMock;
}
private function mockQueryBuilderAndHandler($userToSearch, $salesforceUsers): void
{
app()->bind(QueryBuilder::class, function () use ($userToSearch) {
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->expects($this->once())
->method('buildGetUsersQuery')
->with($userToSearch)
->willReturn('SELECT * FROM Users');
return $queryBuilder;
});
$queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults($salesforceUsers, count($salesforceUsers), true, null));
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('query')
->willReturn($queryIterator);
return $handler;
});
}
private function mockTeamRepository(
Team $team,
array $salesforceUser,
?User $user = null,
bool $userSearch = true,
array $customRules = []
): void {
$teamRepository = $this->getMockForAbstractClass(TeamRepository::class, [], '', false, true, true, ['findActiveTeamMemberByEmail', 'getTeamSetting']);
if ($userSearch) {
$teamRepository->expects($this->once())
->method('findActiveTeamMemberByEmail')
->with($team, $salesforceUser['Email'])
->willReturn($user);
}
$teamSettings = $this->createMock(TeamSettings::class);
$teamSettings->method('getValueType')
->willReturn('array');
$teamSettings->method('getValue')
->willReturn(json_encode($customRules));
$teamRepository->expects($this->once())
->method('getTeamSetting')
->with($team, 'custom_profile_validation')
->willReturn($teamSettings);
app()->bind(TeamRepository::class, function () use ($teamRepository) {
return $teamRepository;
});
}
private function mockProfileRepository(User $user): void
{
$profileRepository = $this->createMock(ProfileRepository::class);
$profileRepository->expects($this->once())
->method('updateOrCreateProfile')
->with(
$user,
[
'crm_configuration_id' => 1,
'crm_provider_id' => '456',
],
[
'user_id' => 123,
'edition' => Profile::EDITION_LIGHTNING,
'has_external_cti' => true,
'crm_profile_id' => '789',
]
)
->willReturn(new Profile());
$this->app->instance(ProfileRepository::class, $profileRepository);
}
public function testBuildEnhancedNoteDecodesHtmlEntities(): void
{
$service = $this->getServiceMock(['createRecord']);
$profile = new Profile();
$profile->setAttribute('crm_provider_id', 'owner-123');
$service->profile = $profile;
$service->expects($this->exactly(2))
->method('createRecord')
->willReturnOnConsecutiveCalls('note-id-123', 'link-id-456');
$bodyWithEntities = 'Welch's current challenges and Facebook's Club';
$result = $this->invokePrivateMethod('buildEnhancedNote', $service, [
'Test Title',
$bodyWithEntities,
'object-id-789',
]);
$this->assertEquals('note-id-123', $result);
}
public function testBuildEnhancedNoteSanitizesWithoutQuotes(): void
{
$service = $this->getServiceMock(['createRecord']);
$profile = new Profile();
$profile->setAttribute('crm_provider_id', 'owner-456');
$service->profile = $profile;
$service->expects($this->exactly(2))
->method('createRecord')
->willReturnCallback(function ($type, $data) {
if ($type === 'ContentNote') {
$decoded = base64_decode($data['Content']);
$this->assertStringContainsString("Welch's", $decoded);
$this->assertStringNotContainsString(''', $decoded);
$this->assertStringNotContainsString('&#039;', $decoded);
$this->assertStringContainsString('<script>', $decoded);
return 'note-id-456';
}
return 'link-id-789';
});
$bodyWithMixedContent = "Welch's and <script>alert('xss')</script>";
$result = $this->invokePrivateMethod('buildEnhancedNote', $service, [
'Test Title',
$bodyWithMixedContent,
'object-id-123',
]);
$this->assertEquals('note-id-456', $result);
}
public function testBuildEnhancedNoteConvertsLineBreaks(): void
{
$service = $this->getServiceMock(['createRecord']);
$profile = new Profile();
$profile->setAttribute('crm_provider_id', 'owner-789');
$service->profile = $profile;
$service->expects($this->exactly(2))
->method('createRecord')
->willReturnCallback(function ($type, $data) {
if ($type === 'ContentNote') {
$decoded = base64_decode($data['Content']);
$this->assertStringContainsString('<br>', $decoded);
$this->assertStringNotContainsString('<br />', $decoded);
return 'note-id-789';
}
return 'link-id-012';
});
$bodyWithLineBreaks = "Line 1\nLine 2\nLine 3";
$result = $this->invokePrivateMethod('buildEnhancedNote', $service, [
'Test Title',
$bodyWithLineBreaks,
'object-id-456',
]);
$this->assertEquals('note-id-789', $result);
}
public function testBuildEnhancedNoteHandlesComplexScenario(): void
{
$service = $this->getServiceMock(['createRecord']);
$profile = new Profile();
$profile->setAttribute('crm_provider_id', 'owner-complex');
$service->profile = $profile;
$service->expects($this->exactly(2))
->method('createRecord')
->willReturnCallback(function ($type, $data) {
if ($type === 'ContentNote') {
$decoded = base64_decode($data['Content']);
$this->assertStringContainsString("Welch's", $decoded);
$this->assertStringContainsString("Facebook's Club", $decoded);
$this->assertStringContainsString("Arctics'", $decoded);
$this->assertStringNotContainsString(''', $decoded);
$this->assertStringNotContainsString('&#039;', $decoded);
$this->assertStringContainsString('<br>', $decoded);
$this->assertStringContainsString('<', $decoded);
$this->assertStringContainsString('>', $decoded);
return 'note-complex';
}
return 'link-complex';
});
$complexBody = "Summary:\n---------\nThe call focused on understanding Welch's current challenges and exploring how Arctics' Revenue Growth Management solutions could support their strategic goals.\n\n• John SMith discussed his role as a category advisor for Google and Facebook's Club, emphasizing the importance of market research and advising on product assortment.\n• Madona introduced Arctics' Virtual Shoppers AI, which simulates consumer <behavior> to optimize pricing and promotional strategies.";
$result = $this->invokePrivateMethod('buildEnhancedNote', $service, [
'Jiminny Transcription Summary',
$complexBody,
'task-id-001',
]);
$this->assertEquals('note-complex', $result);
}
public function testSyncRemotelyDeletedObjectsWithErrorHandlingSuccess(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$prospectPhotoPathService,
])
->onlyMethods(['syncRemotelyDeletedObjects'])
->getMock();
// Mock team
$team = $this->createMock(Team::class);
$team->method('getUuid')->willReturn('team-uuid-123');
$serviceMock->team = $team;
// Expect syncRemotelyDeletedObjects to be called once and succeed
$serviceMock->expects($this->once())
->method('syncRemotelyDeletedObjects')
->with(\Jiminny\Enums\CrmObject::ACCOUNT);
// Call the protected method using reflection
$reflection = new \ReflectionClass($serviceMock);
$method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');
$method->setAccessible(true);
// Should not throw any exceptions
$method->invoke($serviceMock, \Jiminny\Enums\CrmObject::ACCOUNT);
$this->assertTrue(true); // Test completed successfully
}
public function testSyncRemotelyDeletedObjectsWithErrorHandlingFailure(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$prospectPhotoPathService,
])
->onlyMethods(['syncRemotelyDeletedObjects'])
->getMock();
// Mock team
$team = $this->createMock(Team::class);
$team->method('getUuid')->willReturn('team-uuid-456');
$serviceMock->team = $team;
// Mock logger to verify warning is logged
$logger = $this->createMock(\Psr\Log\LoggerInterface::class);
// Use reflection to set the protected logger property
$reflection = new \ReflectionClass($serviceMock);
$loggerProperty = $reflection->getProperty('logger');
$loggerProperty->setAccessible(true);
$loggerProperty->setValue($serviceMock, $logger);
$exception = new \Exception('Sync failed due to API error');
// Expect syncRemotelyDeletedObjects to throw an exception
$serviceMock->expects($this->once())
->method('syncRemotelyDeletedObjects')
->with(\Jiminny\Enums\CrmObject::CONTACT)
->willThrowException($exception);
// Expect warning to be logged with correct message and parameters
$logger->expects($this->once())
->method('warning')
->with(
'[Salesforce] Remotely deleted objects sync failed',
[
'objectType' => 'contact',
'teamId' => 'team-uuid-456',
'reason' => 'Sync failed due to API error',
]
);
// Call the protected method using reflection
$reflection = new \ReflectionClass($serviceMock);
$method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');
$method->setAccessible(true);
// Should not re-throw the exception, just log it
$method->invoke($serviceMock, \Jiminny\Enums\CrmObject::CONTACT);
$this->assertTrue(true); // Test completed successfully
}
public function testSyncRemotelyDeletedObjectsWithErrorHandlingWithLogParams(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$prospectPhotoPathService,
])
->onlyMethods(['syncRemotelyDeletedObjects'])
->getMock();
// Mock team
$team = $this->createMock(Team::class);
$team->method('getUuid')->willReturn('team-uuid-789');
$serviceMock->team = $team;
// Mock logger to verify warning is logged
$logger = $this->createMock(\Psr\Log\LoggerInterface::class);
// Use reflection to set the protected logger property
$loggerReflection = new \ReflectionClass($serviceMock);
$loggerProperty = $loggerReflection->getProperty('logger');
$loggerProperty->setAccessible(true);
$loggerProperty->setValue($serviceMock, $logger);
$exception = new \Exception('Network timeout');
// Expect syncRemotelyDeletedObjects to throw an exception
$serviceMock->expects($this->once())
->method('syncRemotelyDeletedObjects')
->with(\Jiminny\Enums\CrmObject::OPPORTUNITY)
->willThrowException($exception);
// Additional log parameters
$logParams = [
'syncType' => 'full',
'batchSize' => 100,
];
// Expect warning to be logged with merged parameters
$logger->expects($this->once())
->method('warning')
->with(
'[Salesforce] Remotely deleted objects sync failed',
[
'objectType' => 'opportunity',
'teamId' => 'team-uuid-789',
'reason' => 'Network timeout',
'syncType' => 'full',
'batchSize' => 100,
]
);
// Call the protected method using reflection
$reflection = new \ReflectionClass($serviceMock);
$method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');
...
|
[{"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-20963-fix-import-on-deleted-entity, menu","depth":5,"on_screen":true,"help_text":"Git Branch: JY-20963-fix-import-on-deleted-entity","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"ServiceTest","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Rerun 'PHPUnit: ServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'ServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Stop 'ServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Built-in Preview","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":"Chrome","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":"Firefox","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":"Safari","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":"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":"4","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"32","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"168","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"28","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Services\\Crm\\Salesforce;\n\nuse Illuminate\\Database\\Eloquent\\Relations\\HasMany;\nuse Illuminate\\Events\\Dispatcher;\nuse Illuminate\\Support\\Facades\\Cache;\nuse Jiminny\\Component\\Country\\CountriesMap;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Enums\\CrmObject;\nuse Jiminny\\Exceptions\\InvalidArgumentException;\nuse Jiminny\\Exceptions\\NoResultsException;\nuse Jiminny\\Integrations\\PlaybookResolver;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Crm\\Configuration;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\TeamSettings;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\ContactRoleRepository;\nuse Jiminny\\Repositories\\Crm\\FieldDataRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\Salesforce\\Client;\nuse Jiminny\\Services\\Crm\\Salesforce\\PayloadBuilder;\nuse Jiminny\\Services\\Crm\\Salesforce\\QueryBuilder;\nuse Jiminny\\Services\\Crm\\Salesforce\\QueryHandler;\nuse Jiminny\\Services\\Crm\\Salesforce\\QueryIterator;\nuse Jiminny\\Services\\Crm\\Salesforce\\QueryResults;\nuse Jiminny\\Services\\Crm\\Salesforce\\Service;\nuse PHPUnit\\Framework\\MockObject\\MockObject;\nuse Tests\\TestCase;\nuse Tests\\Unit\\Traits\\TestPrivateMethod;\n\nclass ServiceTest extends TestCase\n{\n use TestPrivateMethod;\n\n public function testFetchAndAssociateRelatedActivity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $payloadBuilder->method('addCustomLogicFieldsPayload')\n ->willReturnCallback(function ($activity, $payload) {\n return $payload;\n });\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods(['fetchRelatedActivity', 'getPlaybook', 'getPlaybookCategory', 'updateRecord'])\n ->getMock();\n\n $serviceMock->expects($this->once())\n ->method('fetchRelatedActivity')\n ->willReturn([\n 'Id' => 'testId',\n 'Type' => null,\n 'OwnerId' => 'testerUser',\n 'Description' => 'Test description',\n ]);\n\n $user = $this->createMock(User::class);\n $team = $this->createMock(Team::class);\n $user->method('getAttribute')->with('team')->willReturn($team);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityField')->willReturn(null);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_EVENT);\n\n $serviceMock->expects($this->once())\n ->method('getPlaybook')\n ->with($user)\n ->willReturn($playbook);\n\n $serviceMock->expects($this->never())\n ->method('getPlaybookCategory');\n\n $serviceMock->expects($this->never())\n ->method('updateRecord');\n\n $fieldDataRepository = $this->createMock(FieldDataRepository::class);\n $fieldDataRepository->method('getActivityFieldData')->willReturn(collect([]));\n app()->instance(FieldDataRepository::class, $fieldDataRepository);\n\n $config = $this->createMock(Configuration::class);\n $profilesRelation = $this->getMockBuilder(\\Illuminate\\Database\\Eloquent\\Relations\\HasMany::class)\n ->disableOriginalConstructor()\n ->onlyMethods(['get'])\n ->addMethods(['where'])\n ->getMock();\n $profilesRelation->method('where')->willReturnSelf();\n $profilesRelation->method('get')->willReturn(collect([]));\n $config->method('profiles')->willReturn($profilesRelation);\n\n $serviceMock->config = $config;\n $serviceMock->profile = null;\n\n $actualStartTime = \\Carbon\\Carbon::now();\n\n $activity = $this->getMockBuilder(Activity::class)\n ->disableOriginalConstructor()\n ->onlyMethods(['update', 'hasProspect'])\n ->getMock();\n\n $activity->method('update')->willReturn(true);\n $activity->method('hasProspect')->willReturn(true);\n\n $activity->type = Activity::TYPE_CONFERENCE;\n $activity->provider = Activity::PROVIDER_TWILIO;\n $activity->lead_id = 1;\n $activity->user_id = 0;\n $activity->id_string = 'test-activity-id';\n $activity->user = $user;\n\n $activity->actual_start_time = $actualStartTime;\n $activity->uuid = 'c53d8320-f556-4cee-a2f8-5f232f454ca4';\n\n app()->bind(PlaybookResolver::class, function () use ($user) {\n $playbook = $this->createMock(Playbook::class);\n $playbookResolver = $this->createMock(PlaybookResolver::class);\n $playbookResolver->expects($this->once())\n ->method('resolvePlaybookByUser')\n ->with($user)\n ->willReturn($playbook);\n\n return $playbookResolver;\n });\n\n $data = $serviceMock->fetchAndAssociateRelatedActivity($activity);\n\n $this->assertInstanceOf(Activity::class, $data);\n $this->assertEquals(Activity::TYPE_CONFERENCE, $data->getType());\n $this->assertEquals($actualStartTime->getTimestamp(), $data->getActualStartTime()->getTimestamp());\n }\n\n public function testFetchAndAssociateRelatedActivitySkipsForTaskBasedPlaybook(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods(['fetchRelatedActivity', 'getPlaybook'])\n ->getMock();\n\n $user = $this->createMock(User::class);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n $playbook->method('getId')->willReturn(123);\n\n $serviceMock->expects($this->once())\n ->method('getPlaybook')\n ->with($user)\n ->willReturn($playbook);\n\n $serviceMock->expects($this->never())\n ->method('fetchRelatedActivity');\n\n $activity = $this->getMockBuilder(Activity::class)\n ->disableOriginalConstructor()\n ->onlyMethods(['hasProspect', 'getUuid'])\n ->getMock();\n\n $activity->method('hasProspect')->willReturn(true);\n $activity->method('getUuid')->willReturn('c53d8320-f556-4cee-a2f8-5f232f454ca4');\n $activity->type = Activity::TYPE_CONFERENCE;\n $activity->actual_start_time = \\Carbon\\Carbon::now();\n $activity->user = $user;\n\n $result = $serviceMock->fetchAndAssociateRelatedActivity($activity);\n\n $this->assertNull($result);\n }\n\n public function testFetchAndAssociateRelatedActivityReturnsNullForNonConference(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $serviceMock = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)\n );\n\n $activity = $this->getMockBuilder(Activity::class)\n ->disableOriginalConstructor()\n ->getMock();\n\n $activity->type = Activity::TYPE_SOFTPHONE;\n\n $result = $serviceMock->fetchAndAssociateRelatedActivity($activity);\n\n $this->assertNull($result);\n }\n\n public function testFetchAndAssociateRelatedActivityReturnsNullWhenNoStartTime(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $serviceMock = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)\n );\n\n\n $activity = $this->getMockBuilder(Activity::class)\n ->disableOriginalConstructor()\n ->getMock();\n\n $activity->type = Activity::TYPE_CONFERENCE;\n $activity->actual_start_time = null;\n $activity->scheduled_start_time = null;\n\n $result = $serviceMock->fetchAndAssociateRelatedActivity($activity);\n\n $this->assertNull($result);\n }\n\n public function testFetchAndAssociateRelatedActivityReturnsNullWhenNoProspect(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods(['getPlaybook'])\n ->getMock();\n\n $serviceMock->expects($this->never())\n ->method('getPlaybook');\n\n $activity = $this->getMockBuilder(Activity::class)\n ->disableOriginalConstructor()\n ->onlyMethods(['hasProspect', 'getUuid'])\n ->getMock();\n\n $activity->method('hasProspect')->willReturn(false);\n $activity->method('getUuid')->willReturn('c53d8320-f556-4cee-a2f8-5f232f454ca4');\n $activity->type = Activity::TYPE_CONFERENCE;\n $activity->actual_start_time = \\Carbon\\Carbon::now();\n\n $result = $serviceMock->fetchAndAssociateRelatedActivity($activity);\n\n $this->assertNull($result);\n }\n\n public function testMatchExactlyByEmail(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods([])\n ->getMock();\n\n $profile = new Profile();\n $profile->setAttribute('id', bin2hex(random_bytes(8)));\n $serviceMock->profile = $profile;\n\n $team = $this->createMock(Team::class);\n $serviceMock->team = $team;\n\n $data = $serviceMock->matchExactlyByEmail(bin2hex(random_bytes(8)) . 'test_email@testserver.com');\n\n $this->assertEquals(null, $data);\n }\n\n public function testMatchDomainFromEmail(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $queryIterator = $this->createMock(QueryIterator::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $config = $this->createMock(Configuration::class);\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('search')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods(['convertCrmData'])\n ->getMock();\n\n $profile = new Profile();\n $profile->account_fields = 'Field1, Field2, Field3';\n $serviceMock->profile = $profile;\n\n $serviceMock->expects($this->once())\n ->method('convertCrmData')\n ->willReturn(['test']);\n\n $this->app->bind(QueryBuilder::class, function () {\n $queryBuilder = $this->createMock(QueryBuilder::class);\n $queryBuilder->expects($this->once())\n ->method('buildMatchByDomainQuery')\n ->with('test_email@testserver.com')\n ->willReturn('FIND {test_email@testserver.com} IN ALL FIELDS RETURNING Account(Id)');\n\n return $queryBuilder;\n });\n\n $team = $this->createMock(Team::class);\n\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n\n $data = $serviceMock->matchByDomain('test_email@testserver.com');\n\n $this->assertEquals(['test'], $data);\n }\n\n public function testBuildTaskSearchFields(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)\n );\n\n $fields = $service->buildTaskSearchFields();\n\n $expectedFields = ['Id', 'WhoId', 'WhatId', 'AccountId'];\n\n $this->assertEquals($expectedFields, $fields);\n }\n\n public function testMapCrmObjects(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class),\n );\n\n $sampleTask = [\n 'WhoId' => '003sampleWhoId',\n 'AccountId' => 'sampleAccountId',\n 'WhatId' => 'sampleWhatId',\n ];\n\n $activityData = $service->mapCrmObjects($sampleTask);\n\n $expectedActivityData = [\n 'contact' => '003sampleWhoId',\n 'account' => 'sampleAccountId',\n 'opportunity' => 'sampleWhatId',\n ];\n\n $this->assertEquals($expectedActivityData, $activityData);\n }\n\n public function testGetInstalledAppVersion(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n $queryIterator = $this->createMock(QueryIterator::class);\n $queryIterator->expects($this->any())\n ->method('current')->willReturn([\n 'SubscriberPackageVersion' => [\n 'MajorVersion' => '1',\n 'MinorVersion' => '0',\n 'PatchVersion' => '1',\n 'BuildNumber' => '0',\n ],\n ]);\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('metadata')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods(array_diff(get_class_methods(Service::class), ['getInstalledAppVersion']))\n ->getMock();\n\n $version = $serviceMock->getInstalledAppVersion();\n\n $this->assertEquals('1010', $version);\n }\n\n public function testSyncProfiles(): void\n {\n $userToSearch = null;\n\n $team = $this->createMock(Team::class);\n $config = $this->createMockedConfiguration();\n $config->expects($this->once())\n ->method('getId')\n ->willReturn(1);\n\n $salesforceUser = [\n 'Email' => 'test@example.com',\n 'UserPreferencesLightningExperiencePreferred' => true,\n 'CallCenterId' => '123',\n 'Id' => '456',\n 'ProfileId' => '789',\n ];\n\n app()->bind(QueryBuilder::class, function () use ($userToSearch) {\n $queryBuilder = $this->createMock(QueryBuilder::class);\n $queryBuilder->expects($this->once())\n ->method('buildGetUsersQuery')\n ->with($userToSearch)\n ->willReturn('SELECT * FROM Users');\n\n return $queryBuilder;\n });\n\n $queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('query')\n ->with('SELECT * FROM Users')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n\n $user = $this->createMock(User::class);\n $user->expects($this->once())\n ->method('getId')\n ->willReturn(123);\n\n $this->mockTeamRepository($team, $salesforceUser, $user);\n\n $profileRepository = $this->createMock(ProfileRepository::class);\n $profileRepository->expects($this->once())\n ->method('updateOrCreateProfile')\n ->with(\n $user,\n [\n 'crm_configuration_id' => 1,\n 'crm_provider_id' => '456',\n ],\n [\n 'user_id' => 123,\n 'edition' => Profile::EDITION_LIGHTNING,\n 'has_external_cti' => true,\n 'crm_profile_id' => '789',\n ]\n )\n ->willReturn(new Profile());\n\n $this->app->instance(ProfileRepository::class, $profileRepository);\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n $result = $serviceMock->syncProfiles($userToSearch);\n\n $this->assertNull($result);\n }\n\n public function testSyncProfilesEmailIsNull(): void\n {\n $userToSearch = $this->createMock(User::class);\n\n $salesforceUser = [\n 'Email' => null,\n ];\n\n app()->bind(QueryBuilder::class, function () {\n $queryBuilder = $this->createMock(QueryBuilder::class);\n $queryBuilder->expects($this->once())\n ->method('buildGetUsersQuery')\n ->with(null)\n ->willReturn('SELECT * FROM Users');\n\n return $queryBuilder;\n });\n\n $queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->exactly(2))\n ->method('query')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n\n $team = $this->createMock(Team::class);\n $user = $this->createMock(User::class);\n $this->mockTeamRepository($team, $salesforceUser, $user, false);\n\n $config = $this->createMock(Configuration::class);\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n\n $profile = $serviceMock->syncProfiles(null);\n\n $this->assertNull($profile);\n }\n\n public function testSyncProfilesUserToSearchMatchesCurrentUser(): void\n {\n $userToSearch = $this->createMock(User::class);\n $userToSearch->expects($this->once())\n ->method('getId')\n ->willReturn(123);\n\n $team = $this->createMock(Team::class);\n $config = $this->createMock(Configuration::class);\n $config->expects($this->once())\n ->method('getId')\n ->willReturn(1);\n\n $salesforceUser = [\n 'Email' => 'test@example.com',\n 'UserPreferencesLightningExperiencePreferred' => true,\n 'CallCenterId' => '123',\n 'Id' => '456',\n 'ProfileId' => '789',\n ];\n\n app()->bind(QueryBuilder::class, function () use ($userToSearch) {\n $queryBuilder = $this->createMock(QueryBuilder::class);\n $queryBuilder->expects($this->once())\n ->method('buildGetUsersQuery')\n ->with($userToSearch)\n ->willReturn('SELECT * FROM Users');\n\n return $queryBuilder;\n });\n\n $queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('query')\n ->with('SELECT * FROM Users')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n\n $user = $this->createMock(User::class);\n $user->expects($this->exactly(2))\n ->method('getId')\n ->willReturn(123);\n\n $this->mockTeamRepository($team, $salesforceUser, $user);\n\n $profileRepository = $this->createMock(ProfileRepository::class);\n $profileRepository->expects($this->once())\n ->method('updateOrCreateProfile')\n ->with(\n $user,\n [\n 'crm_configuration_id' => 1,\n 'crm_provider_id' => '456',\n ],\n [\n 'user_id' => 123,\n 'edition' => Profile::EDITION_LIGHTNING,\n 'has_external_cti' => true,\n 'crm_profile_id' => '789',\n ]\n )\n ->willReturn(new Profile());\n\n $this->app->instance(ProfileRepository::class, $profileRepository);\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n $profile = $serviceMock->syncProfiles($userToSearch);\n\n $this->assertInstanceOf(Profile::class, $profile);\n }\n\n public function testSyncProfilesWithCustomValidation(): void\n {\n $userToSearch = null;\n\n $team = $this->createMock(Team::class);\n $config = $this->createMockedConfiguration();\n $config->expects($this->atLeastOnce()) // Changed from once() to atLeastOnce()\n ->method('getId')\n ->willReturn(1);\n\n $salesforceUser = [\n 'Email' => 'test@example.com',\n 'UserPreferencesLightningExperiencePreferred' => true,\n 'CallCenterId' => '123',\n 'Id' => '456',\n 'ProfileId' => '789',\n 'CustomField' => 'CustomValue',\n ];\n\n $customRules = [\n ['field' => 'CustomField', 'value' => 'CustomValue'],\n ];\n\n $this->mockQueryBuilderAndHandler($userToSearch, [$salesforceUser]);\n\n $user = $this->createMock(User::class);\n $user->expects($this->once())\n ->method('getId')\n ->willReturn(123);\n\n $this->mockTeamRepository($team, $salesforceUser, $user, true, $customRules);\n\n $this->mockProfileRepository($user);\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n $profile = $serviceMock->syncProfiles($userToSearch);\n\n $this->assertNull($profile);\n }\n\n public function testSyncProfilesWithCustomValidationFailing(): void\n {\n $userToSearch = null;\n\n $team = $this->createMock(Team::class);\n $config = $this->createMockedConfiguration();\n\n $salesforceUser = [\n 'Email' => 'test@example.com',\n 'UserPreferencesLightningExperiencePreferred' => true,\n 'CallCenterId' => '123',\n 'Id' => '456',\n 'ProfileId' => '789',\n 'CustomField' => 'WrongValue',\n ];\n\n $customRules = [\n ['field' => 'CustomField', 'value' => 'CustomValue'],\n ];\n\n $this->mockQueryBuilderAndHandler($userToSearch, [$salesforceUser]);\n\n $teamRepository = $this->getMockForAbstractClass(TeamRepository::class, [], '', false, true, true, ['findActiveTeamMemberByEmail', 'getTeamSetting']);\n\n $teamSettings = $this->createMock(TeamSettings::class);\n $teamSettings->method('getValueType')\n ->willReturn('array');\n\n $teamSettings->method('getValue')\n ->willReturn(json_encode($customRules));\n\n $teamRepository->expects($this->once())\n ->method('getTeamSetting')\n ->with($team, 'custom_profile_validation')\n ->willReturn($teamSettings);\n\n app()->bind(TeamRepository::class, function () use ($teamRepository) {\n return $teamRepository;\n });\n\n $profileRepository = $this->createMock(ProfileRepository::class);\n $profileRepository->expects($this->never())\n ->method('updateOrCreateProfile');\n\n $this->app->instance(ProfileRepository::class, $profileRepository);\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n $result = $serviceMock->syncProfiles($userToSearch);\n\n $this->assertNull($result);\n }\n\n public function testGetContactRolesFromCrm(): void\n {\n $contactRoles = [\n [\n 'Id' => '1',\n 'ContactId' => 'Contact1',\n 'OpportunityId' => 'Opportunity1',\n 'Opportunity' => ['OwnerId' => 'Owner1'],\n 'IsPrimary' => true,\n 'Role' => 'Decision Maker',\n ],\n ];\n\n $expectedResponse = [\n [\n 'id' => '1',\n 'contactId' => 'Contact1',\n 'opportunityId' => 'Opportunity1',\n 'ownerId' => 'Owner1',\n 'isPrimary' => true,\n 'role' => 'Decision Maker',\n ],\n ];\n\n $this->bindQueryIterator($contactRoles);\n\n $serviceMock = $this->getServiceMock();\n\n $data = $serviceMock->getContactRolesFromCrm(now()->subDay());\n\n $this->assertEquals($expectedResponse, $data);\n }\n\n public function testGetContactRolesFromCrmNoResult(): void\n {\n $this->bindQueryIterator([]);\n\n $serviceMock = $this->getServiceMock();\n\n $data = $serviceMock->getContactRolesFromCrm(now()->subDay());\n\n $this->assertEquals([], $data);\n }\n\n public function testSyncContactRoles(): void\n {\n $contactRoles = [\n [\n 'id' => '1',\n 'contactId' => 'Contact1',\n 'opportunityId' => 'Opportunity1',\n 'ownerId' => 'Owner1',\n 'isPrimary' => true,\n 'role' => 'Decision Maker',\n ],\n ];\n\n app()->bind(ContactRoleRepository::class, function () {\n $contactRoleRepository = $this->createMock(ContactRoleRepository::class);\n $contactRoleRepository->expects($this->once())\n ->method('saveContactRoles');\n\n return $contactRoleRepository;\n });\n\n $serviceMock = $this->getServiceMock([\n 'getContactRolesFromCrm',\n 'syncRemotelyDeletedContactRoles',\n 'syncContact',\n 'syncOpportunity',\n ]);\n\n $config = $this->createMock(Configuration::class);\n $hasMany = $this->createMock(HasManyExtended::class);\n $hasMany->expects($this->exactly(2))\n ->method('where')\n ->willReturn($hasMany);\n\n $hasMany->expects($this->exactly(2))\n ->method('first')\n ->willReturn(\n $this->createMock(Contact::class),\n $this->createMock(Opportunity::class)\n );\n\n $config->expects($this->once())\n ->method('contacts')\n ->willReturn($hasMany);\n $config->expects($this->once())\n ->method('opportunities')\n ->willReturn($hasMany);\n\n $serviceMock->config = $config;\n\n $serviceMock->expects($this->once())\n ->method('getContactRolesFromCrm')\n ->willReturn($contactRoles);\n\n $serviceMock->expects($this->once())\n ->method('syncRemotelyDeletedContactRoles');\n\n $data = $serviceMock->syncContactRoles(now()->subDay());\n\n $this->assertEquals(1, $data);\n }\n\n public function testSyncRemotelyDeletedContactRoles(): void\n {\n $contactRoles = [\n [\n 'id' => '1',\n 'crm_provider_id' => '1',\n ],\n ];\n\n app()->bind(QueryHandler::class, function () use ($contactRoles) {\n $queryResults = new QueryResults($contactRoles, 1, true, null);\n\n $handler = $this->createMock(QueryHandler::class);\n $handler->method('queryDeleted')\n ->willReturn($queryResults);\n\n return $handler;\n });\n\n app()->bind(ContactRoleRepository::class, function () {\n $contactRoleRepository = $this->createMock(ContactRoleRepository::class);\n $contactRoleRepository->expects($this->once())\n ->method('deleteContactRoles');\n\n return $contactRoleRepository;\n });\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $this->createMock(Team::class);\n\n $data = $this->invokePrivateMethod('syncRemotelyDeletedContactRoles', $serviceMock, []);\n\n $this->assertTrue($data);\n }\n\n private function bindQueryIterator(array $queryResult): void\n {\n /** @var Client $client */\n $client = $this->createMock(Client::class);\n $queryIterator = new QueryIterator(\n $client,\n new QueryResults($queryResult, 1, true, null)\n );\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('query')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n }\n\n public static function getOpportunitySortOrderDataProvider(): array\n {\n return [\n 'all open recently updated' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED, ['LastModifiedDate DESC', true]],\n 'all open recently created' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED, ['CreatedDate DESC', true]],\n 'all open oldest created' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED, ['CreatedDate ASC', true]],\n 'all recently updated' => [Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED, ['LastModifiedDate DESC', false]],\n 'default' => ['unknown', ['LastModifiedDate DESC', true]],\n ];\n }\n\n private function createMockedConfiguration(): Configuration\n {\n $config = $this->createMock(Configuration::class);\n $profilesRelation = $this->getMockBuilder(\\Illuminate\\Database\\Eloquent\\Relations\\HasMany::class)\n ->disableOriginalConstructor()\n ->onlyMethods(['get'])\n ->addMethods(['where', 'first'])\n ->getMock();\n $profilesRelation->method('where')->willReturnSelf();\n $profilesRelation->method('get')->willReturn(collect([]));\n $profilesRelation->method('first')->willReturn(null);\n $config->method('profiles')->willReturn($profilesRelation);\n\n return $config;\n }\n\n private function getServiceMock(array $onlyMethods = []): MockObject&Service\n {\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $this->createMock(Client::class),\n $this->createMock(PayloadBuilder::class),\n $this->createMock(Dispatcher::class),\n $this->createMock(CountriesMap::class),\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods($onlyMethods)\n ->getMock();\n\n $serviceMock->profile = $this->createMock(Profile::class);\n\n return $serviceMock;\n }\n\n private function mockQueryBuilderAndHandler($userToSearch, $salesforceUsers): void\n {\n app()->bind(QueryBuilder::class, function () use ($userToSearch) {\n $queryBuilder = $this->createMock(QueryBuilder::class);\n $queryBuilder->expects($this->once())\n ->method('buildGetUsersQuery')\n ->with($userToSearch)\n ->willReturn('SELECT * FROM Users');\n\n return $queryBuilder;\n });\n\n $queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults($salesforceUsers, count($salesforceUsers), true, null));\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('query')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n }\n\n private function mockTeamRepository(\n Team $team,\n array $salesforceUser,\n ?User $user = null,\n bool $userSearch = true,\n array $customRules = []\n ): void {\n $teamRepository = $this->getMockForAbstractClass(TeamRepository::class, [], '', false, true, true, ['findActiveTeamMemberByEmail', 'getTeamSetting']);\n\n if ($userSearch) {\n $teamRepository->expects($this->once())\n ->method('findActiveTeamMemberByEmail')\n ->with($team, $salesforceUser['Email'])\n ->willReturn($user);\n }\n\n $teamSettings = $this->createMock(TeamSettings::class);\n $teamSettings->method('getValueType')\n ->willReturn('array');\n\n $teamSettings->method('getValue')\n ->willReturn(json_encode($customRules));\n\n $teamRepository->expects($this->once())\n ->method('getTeamSetting')\n ->with($team, 'custom_profile_validation')\n ->willReturn($teamSettings);\n\n app()->bind(TeamRepository::class, function () use ($teamRepository) {\n return $teamRepository;\n });\n }\n\n private function mockProfileRepository(User $user): void\n {\n $profileRepository = $this->createMock(ProfileRepository::class);\n $profileRepository->expects($this->once())\n ->method('updateOrCreateProfile')\n ->with(\n $user,\n [\n 'crm_configuration_id' => 1,\n 'crm_provider_id' => '456',\n ],\n [\n 'user_id' => 123,\n 'edition' => Profile::EDITION_LIGHTNING,\n 'has_external_cti' => true,\n 'crm_profile_id' => '789',\n ]\n )\n ->willReturn(new Profile());\n\n $this->app->instance(ProfileRepository::class, $profileRepository);\n }\n\n public function testBuildEnhancedNoteDecodesHtmlEntities(): void\n {\n $service = $this->getServiceMock(['createRecord']);\n\n $profile = new Profile();\n $profile->setAttribute('crm_provider_id', 'owner-123');\n $service->profile = $profile;\n\n $service->expects($this->exactly(2))\n ->method('createRecord')\n ->willReturnOnConsecutiveCalls('note-id-123', 'link-id-456');\n\n $bodyWithEntities = 'Welch's current challenges and Facebook's Club';\n\n $result = $this->invokePrivateMethod('buildEnhancedNote', $service, [\n 'Test Title',\n $bodyWithEntities,\n 'object-id-789',\n ]);\n\n $this->assertEquals('note-id-123', $result);\n }\n\n public function testBuildEnhancedNoteSanitizesWithoutQuotes(): void\n {\n $service = $this->getServiceMock(['createRecord']);\n\n $profile = new Profile();\n $profile->setAttribute('crm_provider_id', 'owner-456');\n $service->profile = $profile;\n\n $service->expects($this->exactly(2))\n ->method('createRecord')\n ->willReturnCallback(function ($type, $data) {\n if ($type === 'ContentNote') {\n $decoded = base64_decode($data['Content']);\n $this->assertStringContainsString(\"Welch's\", $decoded);\n $this->assertStringNotContainsString(''', $decoded);\n $this->assertStringNotContainsString('&#039;', $decoded);\n $this->assertStringContainsString('<script>', $decoded);\n\n return 'note-id-456';\n }\n\n return 'link-id-789';\n });\n\n $bodyWithMixedContent = \"Welch's and <script>alert('xss')</script>\";\n\n $result = $this->invokePrivateMethod('buildEnhancedNote', $service, [\n 'Test Title',\n $bodyWithMixedContent,\n 'object-id-123',\n ]);\n\n $this->assertEquals('note-id-456', $result);\n }\n\n public function testBuildEnhancedNoteConvertsLineBreaks(): void\n {\n $service = $this->getServiceMock(['createRecord']);\n\n $profile = new Profile();\n $profile->setAttribute('crm_provider_id', 'owner-789');\n $service->profile = $profile;\n\n $service->expects($this->exactly(2))\n ->method('createRecord')\n ->willReturnCallback(function ($type, $data) {\n if ($type === 'ContentNote') {\n $decoded = base64_decode($data['Content']);\n $this->assertStringContainsString('<br>', $decoded);\n $this->assertStringNotContainsString('<br />', $decoded);\n\n return 'note-id-789';\n }\n\n return 'link-id-012';\n });\n\n $bodyWithLineBreaks = \"Line 1\\nLine 2\\nLine 3\";\n\n $result = $this->invokePrivateMethod('buildEnhancedNote', $service, [\n 'Test Title',\n $bodyWithLineBreaks,\n 'object-id-456',\n ]);\n\n $this->assertEquals('note-id-789', $result);\n }\n\n public function testBuildEnhancedNoteHandlesComplexScenario(): void\n {\n $service = $this->getServiceMock(['createRecord']);\n\n $profile = new Profile();\n $profile->setAttribute('crm_provider_id', 'owner-complex');\n $service->profile = $profile;\n\n $service->expects($this->exactly(2))\n ->method('createRecord')\n ->willReturnCallback(function ($type, $data) {\n if ($type === 'ContentNote') {\n $decoded = base64_decode($data['Content']);\n\n $this->assertStringContainsString(\"Welch's\", $decoded);\n $this->assertStringContainsString(\"Facebook's Club\", $decoded);\n $this->assertStringContainsString(\"Arctics'\", $decoded);\n $this->assertStringNotContainsString(''', $decoded);\n $this->assertStringNotContainsString('&#039;', $decoded);\n $this->assertStringContainsString('<br>', $decoded);\n $this->assertStringContainsString('<', $decoded);\n $this->assertStringContainsString('>', $decoded);\n\n return 'note-complex';\n }\n\n return 'link-complex';\n });\n\n $complexBody = \"Summary:\\n---------\\nThe call focused on understanding Welch's current challenges and exploring how Arctics' Revenue Growth Management solutions could support their strategic goals.\\n\\n• John SMith discussed his role as a category advisor for Google and Facebook's Club, emphasizing the importance of market research and advising on product assortment.\\n• Madona introduced Arctics' Virtual Shoppers AI, which simulates consumer <behavior> to optimize pricing and promotional strategies.\";\n\n $result = $this->invokePrivateMethod('buildEnhancedNote', $service, [\n 'Jiminny Transcription Summary',\n $complexBody,\n 'task-id-001',\n ]);\n\n $this->assertEquals('note-complex', $result);\n }\n\n public function testSyncRemotelyDeletedObjectsWithErrorHandlingSuccess(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['syncRemotelyDeletedObjects'])\n ->getMock();\n\n // Mock team\n $team = $this->createMock(Team::class);\n $team->method('getUuid')->willReturn('team-uuid-123');\n $serviceMock->team = $team;\n\n // Expect syncRemotelyDeletedObjects to be called once and succeed\n $serviceMock->expects($this->once())\n ->method('syncRemotelyDeletedObjects')\n ->with(\\Jiminny\\Enums\\CrmObject::ACCOUNT);\n\n // Call the protected method using reflection\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');\n $method->setAccessible(true);\n\n // Should not throw any exceptions\n $method->invoke($serviceMock, \\Jiminny\\Enums\\CrmObject::ACCOUNT);\n\n $this->assertTrue(true); // Test completed successfully\n }\n\n public function testSyncRemotelyDeletedObjectsWithErrorHandlingFailure(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['syncRemotelyDeletedObjects'])\n ->getMock();\n\n // Mock team\n $team = $this->createMock(Team::class);\n $team->method('getUuid')->willReturn('team-uuid-456');\n $serviceMock->team = $team;\n\n // Mock logger to verify warning is logged\n $logger = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n\n // Use reflection to set the protected logger property\n $reflection = new \\ReflectionClass($serviceMock);\n $loggerProperty = $reflection->getProperty('logger');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($serviceMock, $logger);\n\n $exception = new \\Exception('Sync failed due to API error');\n\n // Expect syncRemotelyDeletedObjects to throw an exception\n $serviceMock->expects($this->once())\n ->method('syncRemotelyDeletedObjects')\n ->with(\\Jiminny\\Enums\\CrmObject::CONTACT)\n ->willThrowException($exception);\n\n // Expect warning to be logged with correct message and parameters\n $logger->expects($this->once())\n ->method('warning')\n ->with(\n '[Salesforce] Remotely deleted objects sync failed',\n [\n 'objectType' => 'contact',\n 'teamId' => 'team-uuid-456',\n 'reason' => 'Sync failed due to API error',\n ]\n );\n\n // Call the protected method using reflection\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');\n $method->setAccessible(true);\n\n // Should not re-throw the exception, just log it\n $method->invoke($serviceMock, \\Jiminny\\Enums\\CrmObject::CONTACT);\n\n $this->assertTrue(true); // Test completed successfully\n }\n\n public function testSyncRemotelyDeletedObjectsWithErrorHandlingWithLogParams(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['syncRemotelyDeletedObjects'])\n ->getMock();\n\n // Mock team\n $team = $this->createMock(Team::class);\n $team->method('getUuid')->willReturn('team-uuid-789');\n $serviceMock->team = $team;\n\n // Mock logger to verify warning is logged\n $logger = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n\n // Use reflection to set the protected logger property\n $loggerReflection = new \\ReflectionClass($serviceMock);\n $loggerProperty = $loggerReflection->getProperty('logger');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($serviceMock, $logger);\n\n $exception = new \\Exception('Network timeout');\n\n // Expect syncRemotelyDeletedObjects to throw an exception\n $serviceMock->expects($this->once())\n ->method('syncRemotelyDeletedObjects')\n ->with(\\Jiminny\\Enums\\CrmObject::OPPORTUNITY)\n ->willThrowException($exception);\n\n // Additional log parameters\n $logParams = [\n 'syncType' => 'full',\n 'batchSize' => 100,\n ];\n\n // Expect warning to be logged with merged parameters\n $logger->expects($this->once())\n ->method('warning')\n ->with(\n '[Salesforce] Remotely deleted objects sync failed',\n [\n 'objectType' => 'opportunity',\n 'teamId' => 'team-uuid-789',\n 'reason' => 'Network timeout',\n 'syncType' => 'full',\n 'batchSize' => 100,\n ]\n );\n\n // Call the protected method using reflection\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');\n $method->setAccessible(true);\n\n // Should not re-throw the exception, just log it\n $method->invoke($serviceMock, \\Jiminny\\Enums\\CrmObject::OPPORTUNITY, $logParams);\n\n $this->assertTrue(true); // Test completed successfully\n }\n\n /**\n * @dataProvider crmObjectProvider\n */\n public function testSyncRemotelyDeletedObjectsWithErrorHandlingDifferentCrmObjects(\\Jiminny\\Enums\\CrmObject $crmObject): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['syncRemotelyDeletedObjects'])\n ->getMock();\n\n // Mock team\n $team = $this->createMock(Team::class);\n $team->method('getUuid')->willReturn('team-uuid-test');\n $serviceMock->team = $team;\n\n // Mock logger to verify warning is logged\n $logger = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n\n // Use reflection to set the protected logger property\n $loggerReflectionClass = new \\ReflectionClass($serviceMock);\n $loggerProperty = $loggerReflectionClass->getProperty('logger');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($serviceMock, $logger);\n\n $exception = new \\Exception('Test error');\n\n // Expect syncRemotelyDeletedObjects to throw an exception\n $serviceMock->expects($this->once())\n ->method('syncRemotelyDeletedObjects')\n ->with($crmObject)\n ->willThrowException($exception);\n\n // Expect warning to be logged with correct entity type\n $logger->expects($this->once())\n ->method('warning')\n ->with(\n '[Salesforce] Remotely deleted objects sync failed',\n [\n 'objectType' => $crmObject->value,\n 'teamId' => 'team-uuid-test',\n 'reason' => 'Test error',\n ]\n );\n\n // Call the protected method using reflection\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');\n $method->setAccessible(true);\n\n $method->invoke($serviceMock, $crmObject);\n\n $this->assertTrue(true); // Test completed successfully\n }\n\n public function testHandleObjectDeletionWithDeletedEntity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['deleteCrmObject'])\n ->getMock();\n\n $entity = $this->createMock(\\Jiminny\\Models\\Account::class);\n $crmData = ['IsDeleted' => true];\n\n $serviceMock->expects($this->once())\n ->method('deleteCrmObject')\n ->with($entity);\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('handleObjectDeletion');\n $method->setAccessible(true);\n\n $method->invoke($serviceMock, $entity, $crmData);\n }\n\n public function testHandleObjectDeletionWithNonDeletedEntity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['deleteCrmObject'])\n ->getMock();\n\n $entity = $this->createMock(\\Jiminny\\Models\\Contact::class);\n $crmData = ['IsDeleted' => false];\n\n $serviceMock->expects($this->never())\n ->method('deleteCrmObject');\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('handleObjectDeletion');\n $method->setAccessible(true);\n\n $method->invoke($serviceMock, $entity, $crmData);\n }\n\n public function testDeleteCrmObjectWithValidEntity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['dispatchDeleteCrmObjectJob'])\n ->getMock();\n\n $entity = $this->createMock(\\Jiminny\\Models\\Lead::class);\n $entity->expects($this->once())->method('delete');\n\n $serviceMock->expects($this->once())\n ->method('dispatchDeleteCrmObjectJob')\n ->with($entity);\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('deleteCrmObject');\n $method->setAccessible(true);\n\n $method->invoke($serviceMock, $entity);\n }\n\n public function testDeleteCrmObjectWithNullEntity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['dispatchDeleteCrmObjectJob'])\n ->getMock();\n\n $serviceMock->expects($this->never())\n ->method('dispatchDeleteCrmObjectJob');\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('deleteCrmObject');\n $method->setAccessible(true);\n\n $method->invoke($serviceMock, null);\n }\n\n public function testDispatchDeleteCrmObjectJobWithNullEntity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($service);\n $method = $reflection->getMethod('dispatchDeleteCrmObjectJob');\n $method->setAccessible(true);\n\n // Should return early without dispatching - no exception expected\n $method->invoke($service, null);\n\n $this->assertTrue(true); // Test completed successfully\n }\n\n public function testDispatchDeleteCrmObjectJobWithUnsupportedEntity(): void\n {\n $this->expectException(\\TypeError::class);\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n $unsupportedEntity = $this->createMock(\\stdClass::class);\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($service);\n $method = $reflection->getMethod('dispatchDeleteCrmObjectJob');\n\n // This will throw TypeError due to union type constraint\n $method->invoke($service, $unsupportedEntity);\n }\n\n public function testHandleEntityDeletionByProviderIdMethodExists(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n // Test that the method exists and is accessible via reflection\n $reflection = new \\ReflectionClass($service);\n $method = $reflection->getMethod('handleEntityDeletionByProviderId');\n $method->setAccessible(true);\n\n // Verify method exists and has correct parameters\n $this->assertTrue($method->isProtected());\n $this->assertEquals(2, $method->getNumberOfParameters());\n\n $parameters = $method->getParameters();\n $this->assertEquals('targetEntity', $parameters[0]->getName());\n $this->assertEquals('crmData', $parameters[1]->getName());\n }\n\n public function testSyncRemotelyDeletedObjectsWithNoResults(): void\n {\n // Create a real service instance to avoid mock property issues\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n // Mock queryHandler to throw NoResultsException\n $queryHandler = $this->createMock(QueryHandler::class);\n $queryHandler->expects($this->once())\n ->method('queryDeleted')\n ->with('Opportunity')\n ->willThrowException(new NoResultsException('No results'));\n\n // Set the queryHandler using reflection on the real service\n $reflection = new \\ReflectionClass($service);\n $queryHandlerProperty = $reflection->getProperty('queryHandler');\n $queryHandlerProperty->setAccessible(true);\n $queryHandlerProperty->setValue($service, $queryHandler);\n\n $result = self::invokePrivateMethod('syncRemotelyDeletedObjects', $service, [CrmObject::OPPORTUNITY]);\n\n $this->assertFalse($result);\n }\n\n public function testSyncRemotelyDeletedObjectsWithEmptyResults(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n // Mock queryHandler to return empty results\n $queryResult = $this->createMock(QueryResults::class);\n $queryResult->method('getResults')->willReturn([]);\n\n $queryHandler = $this->createMock(QueryHandler::class);\n $queryHandler->expects($this->once())\n ->method('queryDeleted')\n ->with('Opportunity')\n ->willReturn($queryResult);\n\n // Set the queryHandler using reflection on the real service\n $reflection = new \\ReflectionClass($service);\n $queryHandlerProperty = $reflection->getProperty('queryHandler');\n $queryHandlerProperty->setAccessible(true);\n $queryHandlerProperty->setValue($service, $queryHandler);\n\n $result = self::invokePrivateMethod('syncRemotelyDeletedObjects', $service, [CrmObject::OPPORTUNITY]);\n\n $this->assertFalse($result);\n }\n\n public function testSyncRemotelyDeletedObjectsWithUnsupportedCrmObject(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n // Mock queryHandler to return some deleted objects so we reach the match statement\n $deletedObjects = [\n ['id' => 'task1'],\n ['id' => 'task2'],\n ];\n $queryResult = $this->createMock(QueryResults::class);\n $queryResult->method('getResults')->willReturn($deletedObjects);\n\n $queryHandler = $this->createMock(QueryHandler::class);\n $queryHandler->expects($this->once())\n ->method('queryDeleted')\n ->with('Task') // ucfirst('task') = 'Task'\n ->willReturn($queryResult);\n\n self::setPrivateProperty($service, 'queryHandler', $queryHandler);\n\n $this->expectException(InvalidArgumentException::class);\n $this->expectExceptionMessage('Unsupported CrmObject: task');\n\n self::invokePrivateMethod('syncRemotelyDeletedObjects', $service, [CrmObject::TASK]);\n }\n\n public static function crmObjectProvider(): array\n {\n return [\n 'Account' => [CrmObject::ACCOUNT],\n 'Contact' => [CrmObject::CONTACT],\n 'Lead' => [CrmObject::LEAD],\n 'Opportunity' => [CrmObject::OPPORTUNITY],\n ];\n }\n\n public function testVerifyTaskExistsReturnsTrue(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-123', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-123');\n $activity->method('getId')->willReturn(456);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-123', ['Id', 'IsDeleted'])\n ->willReturn(['Id' => 'task-123', 'IsDeleted' => false]);\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertTrue($result);\n }\n\n public function testVerifyTaskExistsReturnsTrueForEvent(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:event-123', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('event-123');\n $activity->method('getId')->willReturn(456);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_EVENT);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Event', 'event-123', ['Id', 'IsDeleted'])\n ->willReturn(['Id' => 'event-123', 'IsDeleted' => false]);\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertTrue($result);\n }\n\n public function testVerifyTaskExistsReturnsFalseWhenDeleted(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-456', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-456');\n $activity->method('getId')->willReturn(789);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-456', ['Id', 'IsDeleted'])\n ->willReturn(['Id' => 'task-456', 'IsDeleted' => true]);\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertFalse($result);\n }\n\n public function testVerifyTaskExistsReturnsFalseWhenNotFound(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-999', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-999');\n $activity->method('getId')->willReturn(999);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-999', ['Id', 'IsDeleted'])\n ->willThrowException(new \\Jiminny\\Exceptions\\HttpNotFoundException('Task not found'));\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertFalse($result);\n }\n\n public function testVerifyTaskExistsReturnsFalseWhenNoPlaybook(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-no-playbook', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-no-playbook');\n $activity->method('getId')->willReturn(111);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn(null);\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertFalse($result);\n }\n\n public function testVerifyTaskExistsThrowsExceptionForTransientErrors(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-error', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-error');\n $activity->method('getId')->willReturn(888);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-error', ['Id', 'IsDeleted'])\n ->willThrowException(new \\RuntimeException('Network timeout'));\n\n $this->expectException(\\RuntimeException::class);\n $this->expectExceptionMessage('Network timeout');\n\n $service->verifyTaskExists($activity);\n }\n\n public function testVerifyTaskExistsCachesResults(): void\n {\n $cachedValue = null;\n Cache::shouldReceive('remember')\n ->twice()\n ->with('crm_task_exists:123:task-cached', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(function ($key, $ttl, $callback) use (&$cachedValue) {\n if ($cachedValue === null) {\n $cachedValue = $callback();\n }\n\n return $cachedValue;\n });\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-cached');\n $activity->method('getId')->willReturn(555);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-cached', ['Id', 'IsDeleted'])\n ->willReturn(['Id' => 'task-cached', 'IsDeleted' => false]);\n\n $result1 = $service->verifyTaskExists($activity);\n $result2 = $service->verifyTaskExists($activity);\n\n $this->assertTrue($result1);\n $this->assertTrue($result2);\n }\n\n public function testVerifyTaskExistsReturnsFalseForHttpBadRequestException(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-400', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-400');\n $activity->method('getId')->willReturn(400);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-400', ['Id', 'IsDeleted'])\n ->willThrowException(new \\Jiminny\\Exceptions\\HttpBadRequestException('Bad request'));\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertFalse($result);\n }\n\n public function testImportOpportunitySkipsWhenNoProfileAndNoAccount(): void\n {\n $crmData = [\n 'Id' => 'SF-NO-USER-1',\n 'Name' => 'Test Opportunity',\n 'OwnerId' => 'owner-no-profile',\n // No AccountId\n ];\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(\\Illuminate\\Events\\Dispatcher::class); // ← ADD THIS\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n\n $service = new Service(\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService\n );\n\n $config = $this->createMock(Configuration::class);\n\n // Mock profiles relation returning null (no profile found)\n $profilesRelation = \\Mockery::mock(\\Illuminate\\Database\\Eloquent\\Relations\\HasMany::class);\n $profilesRelation->shouldReceive('where')->with('crm_provider_id', 'owner-no-profile')->andReturnSelf();\n $profilesRelation->shouldReceive('first')->andReturn(null);\n\n $config->expects($this->once())\n ->method('profiles')\n ->willReturn($profilesRelation);\n\n $team = $this->createMock(Team::class);\n $team->method('getId')->willReturn(1);\n\n $logger = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n $logger->expects($this->once())\n ->method('error')\n ->with(\n '[Salesforce] | Skip import, no user_id found',\n ['id' => 'SF-NO-USER-1']\n );\n\n $reflection = new \\ReflectionClass($service);\n\n $configProperty = $reflection->getProperty('config');\n $configProperty->setAccessible(true);\n $configProperty->setValue($service, $config);\n\n $teamProperty = $reflection->getProperty('team');\n $teamProperty->setAccessible(true);\n $teamProperty->setValue($service, $team);\n\n $loggerProperty = $reflection->getProperty('logger');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($service, $logger);\n\n // Initialize profile property to avoid \"must not be accessed before initialization\" error\n $profileProperty = $reflection->getProperty('profile');\n $profileProperty->setAccessible(true);\n $profileProperty->setValue($service, null);\n\n $result = self::invokePrivateMethod('importOpportunity', $service, [$crmData]);\n\n $this->assertNull($result);\n }\n\n public function testImportOpportunitySkipsWhenProfileNotFound(): void\n {\n $crmData = [\n 'Id' => 'SF-NO-USER-2',\n 'Name' => 'Test Opportunity',\n 'OwnerId' => 'owner-not-found',\n // No AccountId - avoid complex account processing\n ];\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $eventDispatcher = $this->createMock(\\Illuminate\\Events\\Dispatcher::class); // ← ADD THIS\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n\n\n $service = new Service(\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService\n );\n\n $config = $this->createMock(Configuration::class);\n\n // Mock profiles relation returning null (no profile found)\n $profilesRelation = \\Mockery::mock(\\Illuminate\\Database\\Eloquent\\Relations\\HasMany::class);\n $profilesRelation->shouldReceive('where')->with('crm_provider_id', 'owner-not-found')->andReturnSelf();\n $profilesRelation->shouldReceive('first')->andReturn(null);\n\n $config->expects($this->once())\n ->method('profiles')\n ->willReturn($profilesRelation);\n\n $team = $this->createMock(Team::class);\n $team->method('getId')->willReturn(1);\n\n $logger = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n $logger->expects($this->once())\n ->method('error')\n ->with(\n '[Salesforce] | Skip import, no user_id found',\n ['id' => 'SF-NO-USER-2']\n );\n\n $reflection = new \\ReflectionClass($service);\n\n $configProperty = $reflection->getProperty('config');\n $configProperty->setAccessible(true);\n $configProperty->setValue($service, $config);\n\n $teamProperty = $reflection->getProperty('team');\n $teamProperty->setAccessible(true);\n $teamProperty->setValue($service, $team);\n\n $loggerProperty = $reflection->getProperty('logger');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($service, $logger);\n\n // Initialize profile property\n $profileProperty = $reflection->getProperty('profile');\n $profileProperty->setAccessible(true);\n $profileProperty->setValue($service, null);\n\n $result = self::invokePrivateMethod('importOpportunity', $service, [$crmData]);\n\n $this->assertNull($result);\n }\n}\n\nclass HasManyExtended extends HasMany\n{\n public function where()\n {\n }\n\n public function first()\n {\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Services\\Crm\\Salesforce;\n\nuse Illuminate\\Database\\Eloquent\\Relations\\HasMany;\nuse Illuminate\\Events\\Dispatcher;\nuse Illuminate\\Support\\Facades\\Cache;\nuse Jiminny\\Component\\Country\\CountriesMap;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Enums\\CrmObject;\nuse Jiminny\\Exceptions\\InvalidArgumentException;\nuse Jiminny\\Exceptions\\NoResultsException;\nuse Jiminny\\Integrations\\PlaybookResolver;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Crm\\Configuration;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\TeamSettings;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\ContactRoleRepository;\nuse Jiminny\\Repositories\\Crm\\FieldDataRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\Salesforce\\Client;\nuse Jiminny\\Services\\Crm\\Salesforce\\PayloadBuilder;\nuse Jiminny\\Services\\Crm\\Salesforce\\QueryBuilder;\nuse Jiminny\\Services\\Crm\\Salesforce\\QueryHandler;\nuse Jiminny\\Services\\Crm\\Salesforce\\QueryIterator;\nuse Jiminny\\Services\\Crm\\Salesforce\\QueryResults;\nuse Jiminny\\Services\\Crm\\Salesforce\\Service;\nuse PHPUnit\\Framework\\MockObject\\MockObject;\nuse Tests\\TestCase;\nuse Tests\\Unit\\Traits\\TestPrivateMethod;\n\nclass ServiceTest extends TestCase\n{\n use TestPrivateMethod;\n\n public function testFetchAndAssociateRelatedActivity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $payloadBuilder->method('addCustomLogicFieldsPayload')\n ->willReturnCallback(function ($activity, $payload) {\n return $payload;\n });\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods(['fetchRelatedActivity', 'getPlaybook', 'getPlaybookCategory', 'updateRecord'])\n ->getMock();\n\n $serviceMock->expects($this->once())\n ->method('fetchRelatedActivity')\n ->willReturn([\n 'Id' => 'testId',\n 'Type' => null,\n 'OwnerId' => 'testerUser',\n 'Description' => 'Test description',\n ]);\n\n $user = $this->createMock(User::class);\n $team = $this->createMock(Team::class);\n $user->method('getAttribute')->with('team')->willReturn($team);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityField')->willReturn(null);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_EVENT);\n\n $serviceMock->expects($this->once())\n ->method('getPlaybook')\n ->with($user)\n ->willReturn($playbook);\n\n $serviceMock->expects($this->never())\n ->method('getPlaybookCategory');\n\n $serviceMock->expects($this->never())\n ->method('updateRecord');\n\n $fieldDataRepository = $this->createMock(FieldDataRepository::class);\n $fieldDataRepository->method('getActivityFieldData')->willReturn(collect([]));\n app()->instance(FieldDataRepository::class, $fieldDataRepository);\n\n $config = $this->createMock(Configuration::class);\n $profilesRelation = $this->getMockBuilder(\\Illuminate\\Database\\Eloquent\\Relations\\HasMany::class)\n ->disableOriginalConstructor()\n ->onlyMethods(['get'])\n ->addMethods(['where'])\n ->getMock();\n $profilesRelation->method('where')->willReturnSelf();\n $profilesRelation->method('get')->willReturn(collect([]));\n $config->method('profiles')->willReturn($profilesRelation);\n\n $serviceMock->config = $config;\n $serviceMock->profile = null;\n\n $actualStartTime = \\Carbon\\Carbon::now();\n\n $activity = $this->getMockBuilder(Activity::class)\n ->disableOriginalConstructor()\n ->onlyMethods(['update', 'hasProspect'])\n ->getMock();\n\n $activity->method('update')->willReturn(true);\n $activity->method('hasProspect')->willReturn(true);\n\n $activity->type = Activity::TYPE_CONFERENCE;\n $activity->provider = Activity::PROVIDER_TWILIO;\n $activity->lead_id = 1;\n $activity->user_id = 0;\n $activity->id_string = 'test-activity-id';\n $activity->user = $user;\n\n $activity->actual_start_time = $actualStartTime;\n $activity->uuid = 'c53d8320-f556-4cee-a2f8-5f232f454ca4';\n\n app()->bind(PlaybookResolver::class, function () use ($user) {\n $playbook = $this->createMock(Playbook::class);\n $playbookResolver = $this->createMock(PlaybookResolver::class);\n $playbookResolver->expects($this->once())\n ->method('resolvePlaybookByUser')\n ->with($user)\n ->willReturn($playbook);\n\n return $playbookResolver;\n });\n\n $data = $serviceMock->fetchAndAssociateRelatedActivity($activity);\n\n $this->assertInstanceOf(Activity::class, $data);\n $this->assertEquals(Activity::TYPE_CONFERENCE, $data->getType());\n $this->assertEquals($actualStartTime->getTimestamp(), $data->getActualStartTime()->getTimestamp());\n }\n\n public function testFetchAndAssociateRelatedActivitySkipsForTaskBasedPlaybook(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods(['fetchRelatedActivity', 'getPlaybook'])\n ->getMock();\n\n $user = $this->createMock(User::class);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n $playbook->method('getId')->willReturn(123);\n\n $serviceMock->expects($this->once())\n ->method('getPlaybook')\n ->with($user)\n ->willReturn($playbook);\n\n $serviceMock->expects($this->never())\n ->method('fetchRelatedActivity');\n\n $activity = $this->getMockBuilder(Activity::class)\n ->disableOriginalConstructor()\n ->onlyMethods(['hasProspect', 'getUuid'])\n ->getMock();\n\n $activity->method('hasProspect')->willReturn(true);\n $activity->method('getUuid')->willReturn('c53d8320-f556-4cee-a2f8-5f232f454ca4');\n $activity->type = Activity::TYPE_CONFERENCE;\n $activity->actual_start_time = \\Carbon\\Carbon::now();\n $activity->user = $user;\n\n $result = $serviceMock->fetchAndAssociateRelatedActivity($activity);\n\n $this->assertNull($result);\n }\n\n public function testFetchAndAssociateRelatedActivityReturnsNullForNonConference(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $serviceMock = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)\n );\n\n $activity = $this->getMockBuilder(Activity::class)\n ->disableOriginalConstructor()\n ->getMock();\n\n $activity->type = Activity::TYPE_SOFTPHONE;\n\n $result = $serviceMock->fetchAndAssociateRelatedActivity($activity);\n\n $this->assertNull($result);\n }\n\n public function testFetchAndAssociateRelatedActivityReturnsNullWhenNoStartTime(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $serviceMock = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)\n );\n\n\n $activity = $this->getMockBuilder(Activity::class)\n ->disableOriginalConstructor()\n ->getMock();\n\n $activity->type = Activity::TYPE_CONFERENCE;\n $activity->actual_start_time = null;\n $activity->scheduled_start_time = null;\n\n $result = $serviceMock->fetchAndAssociateRelatedActivity($activity);\n\n $this->assertNull($result);\n }\n\n public function testFetchAndAssociateRelatedActivityReturnsNullWhenNoProspect(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods(['getPlaybook'])\n ->getMock();\n\n $serviceMock->expects($this->never())\n ->method('getPlaybook');\n\n $activity = $this->getMockBuilder(Activity::class)\n ->disableOriginalConstructor()\n ->onlyMethods(['hasProspect', 'getUuid'])\n ->getMock();\n\n $activity->method('hasProspect')->willReturn(false);\n $activity->method('getUuid')->willReturn('c53d8320-f556-4cee-a2f8-5f232f454ca4');\n $activity->type = Activity::TYPE_CONFERENCE;\n $activity->actual_start_time = \\Carbon\\Carbon::now();\n\n $result = $serviceMock->fetchAndAssociateRelatedActivity($activity);\n\n $this->assertNull($result);\n }\n\n public function testMatchExactlyByEmail(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods([])\n ->getMock();\n\n $profile = new Profile();\n $profile->setAttribute('id', bin2hex(random_bytes(8)));\n $serviceMock->profile = $profile;\n\n $team = $this->createMock(Team::class);\n $serviceMock->team = $team;\n\n $data = $serviceMock->matchExactlyByEmail(bin2hex(random_bytes(8)) . 'test_email@testserver.com');\n\n $this->assertEquals(null, $data);\n }\n\n public function testMatchDomainFromEmail(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $queryIterator = $this->createMock(QueryIterator::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $config = $this->createMock(Configuration::class);\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('search')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods(['convertCrmData'])\n ->getMock();\n\n $profile = new Profile();\n $profile->account_fields = 'Field1, Field2, Field3';\n $serviceMock->profile = $profile;\n\n $serviceMock->expects($this->once())\n ->method('convertCrmData')\n ->willReturn(['test']);\n\n $this->app->bind(QueryBuilder::class, function () {\n $queryBuilder = $this->createMock(QueryBuilder::class);\n $queryBuilder->expects($this->once())\n ->method('buildMatchByDomainQuery')\n ->with('test_email@testserver.com')\n ->willReturn('FIND {test_email@testserver.com} IN ALL FIELDS RETURNING Account(Id)');\n\n return $queryBuilder;\n });\n\n $team = $this->createMock(Team::class);\n\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n\n $data = $serviceMock->matchByDomain('test_email@testserver.com');\n\n $this->assertEquals(['test'], $data);\n }\n\n public function testBuildTaskSearchFields(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)\n );\n\n $fields = $service->buildTaskSearchFields();\n\n $expectedFields = ['Id', 'WhoId', 'WhatId', 'AccountId'];\n\n $this->assertEquals($expectedFields, $fields);\n }\n\n public function testMapCrmObjects(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class),\n );\n\n $sampleTask = [\n 'WhoId' => '003sampleWhoId',\n 'AccountId' => 'sampleAccountId',\n 'WhatId' => 'sampleWhatId',\n ];\n\n $activityData = $service->mapCrmObjects($sampleTask);\n\n $expectedActivityData = [\n 'contact' => '003sampleWhoId',\n 'account' => 'sampleAccountId',\n 'opportunity' => 'sampleWhatId',\n ];\n\n $this->assertEquals($expectedActivityData, $activityData);\n }\n\n public function testGetInstalledAppVersion(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n $queryIterator = $this->createMock(QueryIterator::class);\n $queryIterator->expects($this->any())\n ->method('current')->willReturn([\n 'SubscriberPackageVersion' => [\n 'MajorVersion' => '1',\n 'MinorVersion' => '0',\n 'PatchVersion' => '1',\n 'BuildNumber' => '0',\n ],\n ]);\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('metadata')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods(array_diff(get_class_methods(Service::class), ['getInstalledAppVersion']))\n ->getMock();\n\n $version = $serviceMock->getInstalledAppVersion();\n\n $this->assertEquals('1010', $version);\n }\n\n public function testSyncProfiles(): void\n {\n $userToSearch = null;\n\n $team = $this->createMock(Team::class);\n $config = $this->createMockedConfiguration();\n $config->expects($this->once())\n ->method('getId')\n ->willReturn(1);\n\n $salesforceUser = [\n 'Email' => 'test@example.com',\n 'UserPreferencesLightningExperiencePreferred' => true,\n 'CallCenterId' => '123',\n 'Id' => '456',\n 'ProfileId' => '789',\n ];\n\n app()->bind(QueryBuilder::class, function () use ($userToSearch) {\n $queryBuilder = $this->createMock(QueryBuilder::class);\n $queryBuilder->expects($this->once())\n ->method('buildGetUsersQuery')\n ->with($userToSearch)\n ->willReturn('SELECT * FROM Users');\n\n return $queryBuilder;\n });\n\n $queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('query')\n ->with('SELECT * FROM Users')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n\n $user = $this->createMock(User::class);\n $user->expects($this->once())\n ->method('getId')\n ->willReturn(123);\n\n $this->mockTeamRepository($team, $salesforceUser, $user);\n\n $profileRepository = $this->createMock(ProfileRepository::class);\n $profileRepository->expects($this->once())\n ->method('updateOrCreateProfile')\n ->with(\n $user,\n [\n 'crm_configuration_id' => 1,\n 'crm_provider_id' => '456',\n ],\n [\n 'user_id' => 123,\n 'edition' => Profile::EDITION_LIGHTNING,\n 'has_external_cti' => true,\n 'crm_profile_id' => '789',\n ]\n )\n ->willReturn(new Profile());\n\n $this->app->instance(ProfileRepository::class, $profileRepository);\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n $result = $serviceMock->syncProfiles($userToSearch);\n\n $this->assertNull($result);\n }\n\n public function testSyncProfilesEmailIsNull(): void\n {\n $userToSearch = $this->createMock(User::class);\n\n $salesforceUser = [\n 'Email' => null,\n ];\n\n app()->bind(QueryBuilder::class, function () {\n $queryBuilder = $this->createMock(QueryBuilder::class);\n $queryBuilder->expects($this->once())\n ->method('buildGetUsersQuery')\n ->with(null)\n ->willReturn('SELECT * FROM Users');\n\n return $queryBuilder;\n });\n\n $queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->exactly(2))\n ->method('query')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n\n $team = $this->createMock(Team::class);\n $user = $this->createMock(User::class);\n $this->mockTeamRepository($team, $salesforceUser, $user, false);\n\n $config = $this->createMock(Configuration::class);\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n\n $profile = $serviceMock->syncProfiles(null);\n\n $this->assertNull($profile);\n }\n\n public function testSyncProfilesUserToSearchMatchesCurrentUser(): void\n {\n $userToSearch = $this->createMock(User::class);\n $userToSearch->expects($this->once())\n ->method('getId')\n ->willReturn(123);\n\n $team = $this->createMock(Team::class);\n $config = $this->createMock(Configuration::class);\n $config->expects($this->once())\n ->method('getId')\n ->willReturn(1);\n\n $salesforceUser = [\n 'Email' => 'test@example.com',\n 'UserPreferencesLightningExperiencePreferred' => true,\n 'CallCenterId' => '123',\n 'Id' => '456',\n 'ProfileId' => '789',\n ];\n\n app()->bind(QueryBuilder::class, function () use ($userToSearch) {\n $queryBuilder = $this->createMock(QueryBuilder::class);\n $queryBuilder->expects($this->once())\n ->method('buildGetUsersQuery')\n ->with($userToSearch)\n ->willReturn('SELECT * FROM Users');\n\n return $queryBuilder;\n });\n\n $queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('query')\n ->with('SELECT * FROM Users')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n\n $user = $this->createMock(User::class);\n $user->expects($this->exactly(2))\n ->method('getId')\n ->willReturn(123);\n\n $this->mockTeamRepository($team, $salesforceUser, $user);\n\n $profileRepository = $this->createMock(ProfileRepository::class);\n $profileRepository->expects($this->once())\n ->method('updateOrCreateProfile')\n ->with(\n $user,\n [\n 'crm_configuration_id' => 1,\n 'crm_provider_id' => '456',\n ],\n [\n 'user_id' => 123,\n 'edition' => Profile::EDITION_LIGHTNING,\n 'has_external_cti' => true,\n 'crm_profile_id' => '789',\n ]\n )\n ->willReturn(new Profile());\n\n $this->app->instance(ProfileRepository::class, $profileRepository);\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n $profile = $serviceMock->syncProfiles($userToSearch);\n\n $this->assertInstanceOf(Profile::class, $profile);\n }\n\n public function testSyncProfilesWithCustomValidation(): void\n {\n $userToSearch = null;\n\n $team = $this->createMock(Team::class);\n $config = $this->createMockedConfiguration();\n $config->expects($this->atLeastOnce()) // Changed from once() to atLeastOnce()\n ->method('getId')\n ->willReturn(1);\n\n $salesforceUser = [\n 'Email' => 'test@example.com',\n 'UserPreferencesLightningExperiencePreferred' => true,\n 'CallCenterId' => '123',\n 'Id' => '456',\n 'ProfileId' => '789',\n 'CustomField' => 'CustomValue',\n ];\n\n $customRules = [\n ['field' => 'CustomField', 'value' => 'CustomValue'],\n ];\n\n $this->mockQueryBuilderAndHandler($userToSearch, [$salesforceUser]);\n\n $user = $this->createMock(User::class);\n $user->expects($this->once())\n ->method('getId')\n ->willReturn(123);\n\n $this->mockTeamRepository($team, $salesforceUser, $user, true, $customRules);\n\n $this->mockProfileRepository($user);\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n $profile = $serviceMock->syncProfiles($userToSearch);\n\n $this->assertNull($profile);\n }\n\n public function testSyncProfilesWithCustomValidationFailing(): void\n {\n $userToSearch = null;\n\n $team = $this->createMock(Team::class);\n $config = $this->createMockedConfiguration();\n\n $salesforceUser = [\n 'Email' => 'test@example.com',\n 'UserPreferencesLightningExperiencePreferred' => true,\n 'CallCenterId' => '123',\n 'Id' => '456',\n 'ProfileId' => '789',\n 'CustomField' => 'WrongValue',\n ];\n\n $customRules = [\n ['field' => 'CustomField', 'value' => 'CustomValue'],\n ];\n\n $this->mockQueryBuilderAndHandler($userToSearch, [$salesforceUser]);\n\n $teamRepository = $this->getMockForAbstractClass(TeamRepository::class, [], '', false, true, true, ['findActiveTeamMemberByEmail', 'getTeamSetting']);\n\n $teamSettings = $this->createMock(TeamSettings::class);\n $teamSettings->method('getValueType')\n ->willReturn('array');\n\n $teamSettings->method('getValue')\n ->willReturn(json_encode($customRules));\n\n $teamRepository->expects($this->once())\n ->method('getTeamSetting')\n ->with($team, 'custom_profile_validation')\n ->willReturn($teamSettings);\n\n app()->bind(TeamRepository::class, function () use ($teamRepository) {\n return $teamRepository;\n });\n\n $profileRepository = $this->createMock(ProfileRepository::class);\n $profileRepository->expects($this->never())\n ->method('updateOrCreateProfile');\n\n $this->app->instance(ProfileRepository::class, $profileRepository);\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n $result = $serviceMock->syncProfiles($userToSearch);\n\n $this->assertNull($result);\n }\n\n public function testGetContactRolesFromCrm(): void\n {\n $contactRoles = [\n [\n 'Id' => '1',\n 'ContactId' => 'Contact1',\n 'OpportunityId' => 'Opportunity1',\n 'Opportunity' => ['OwnerId' => 'Owner1'],\n 'IsPrimary' => true,\n 'Role' => 'Decision Maker',\n ],\n ];\n\n $expectedResponse = [\n [\n 'id' => '1',\n 'contactId' => 'Contact1',\n 'opportunityId' => 'Opportunity1',\n 'ownerId' => 'Owner1',\n 'isPrimary' => true,\n 'role' => 'Decision Maker',\n ],\n ];\n\n $this->bindQueryIterator($contactRoles);\n\n $serviceMock = $this->getServiceMock();\n\n $data = $serviceMock->getContactRolesFromCrm(now()->subDay());\n\n $this->assertEquals($expectedResponse, $data);\n }\n\n public function testGetContactRolesFromCrmNoResult(): void\n {\n $this->bindQueryIterator([]);\n\n $serviceMock = $this->getServiceMock();\n\n $data = $serviceMock->getContactRolesFromCrm(now()->subDay());\n\n $this->assertEquals([], $data);\n }\n\n public function testSyncContactRoles(): void\n {\n $contactRoles = [\n [\n 'id' => '1',\n 'contactId' => 'Contact1',\n 'opportunityId' => 'Opportunity1',\n 'ownerId' => 'Owner1',\n 'isPrimary' => true,\n 'role' => 'Decision Maker',\n ],\n ];\n\n app()->bind(ContactRoleRepository::class, function () {\n $contactRoleRepository = $this->createMock(ContactRoleRepository::class);\n $contactRoleRepository->expects($this->once())\n ->method('saveContactRoles');\n\n return $contactRoleRepository;\n });\n\n $serviceMock = $this->getServiceMock([\n 'getContactRolesFromCrm',\n 'syncRemotelyDeletedContactRoles',\n 'syncContact',\n 'syncOpportunity',\n ]);\n\n $config = $this->createMock(Configuration::class);\n $hasMany = $this->createMock(HasManyExtended::class);\n $hasMany->expects($this->exactly(2))\n ->method('where')\n ->willReturn($hasMany);\n\n $hasMany->expects($this->exactly(2))\n ->method('first')\n ->willReturn(\n $this->createMock(Contact::class),\n $this->createMock(Opportunity::class)\n );\n\n $config->expects($this->once())\n ->method('contacts')\n ->willReturn($hasMany);\n $config->expects($this->once())\n ->method('opportunities')\n ->willReturn($hasMany);\n\n $serviceMock->config = $config;\n\n $serviceMock->expects($this->once())\n ->method('getContactRolesFromCrm')\n ->willReturn($contactRoles);\n\n $serviceMock->expects($this->once())\n ->method('syncRemotelyDeletedContactRoles');\n\n $data = $serviceMock->syncContactRoles(now()->subDay());\n\n $this->assertEquals(1, $data);\n }\n\n public function testSyncRemotelyDeletedContactRoles(): void\n {\n $contactRoles = [\n [\n 'id' => '1',\n 'crm_provider_id' => '1',\n ],\n ];\n\n app()->bind(QueryHandler::class, function () use ($contactRoles) {\n $queryResults = new QueryResults($contactRoles, 1, true, null);\n\n $handler = $this->createMock(QueryHandler::class);\n $handler->method('queryDeleted')\n ->willReturn($queryResults);\n\n return $handler;\n });\n\n app()->bind(ContactRoleRepository::class, function () {\n $contactRoleRepository = $this->createMock(ContactRoleRepository::class);\n $contactRoleRepository->expects($this->once())\n ->method('deleteContactRoles');\n\n return $contactRoleRepository;\n });\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $this->createMock(Team::class);\n\n $data = $this->invokePrivateMethod('syncRemotelyDeletedContactRoles', $serviceMock, []);\n\n $this->assertTrue($data);\n }\n\n private function bindQueryIterator(array $queryResult): void\n {\n /** @var Client $client */\n $client = $this->createMock(Client::class);\n $queryIterator = new QueryIterator(\n $client,\n new QueryResults($queryResult, 1, true, null)\n );\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('query')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n }\n\n public static function getOpportunitySortOrderDataProvider(): array\n {\n return [\n 'all open recently updated' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED, ['LastModifiedDate DESC', true]],\n 'all open recently created' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED, ['CreatedDate DESC', true]],\n 'all open oldest created' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED, ['CreatedDate ASC', true]],\n 'all recently updated' => [Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED, ['LastModifiedDate DESC', false]],\n 'default' => ['unknown', ['LastModifiedDate DESC', true]],\n ];\n }\n\n private function createMockedConfiguration(): Configuration\n {\n $config = $this->createMock(Configuration::class);\n $profilesRelation = $this->getMockBuilder(\\Illuminate\\Database\\Eloquent\\Relations\\HasMany::class)\n ->disableOriginalConstructor()\n ->onlyMethods(['get'])\n ->addMethods(['where', 'first'])\n ->getMock();\n $profilesRelation->method('where')->willReturnSelf();\n $profilesRelation->method('get')->willReturn(collect([]));\n $profilesRelation->method('first')->willReturn(null);\n $config->method('profiles')->willReturn($profilesRelation);\n\n return $config;\n }\n\n private function getServiceMock(array $onlyMethods = []): MockObject&Service\n {\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $this->createMock(Client::class),\n $this->createMock(PayloadBuilder::class),\n $this->createMock(Dispatcher::class),\n $this->createMock(CountriesMap::class),\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods($onlyMethods)\n ->getMock();\n\n $serviceMock->profile = $this->createMock(Profile::class);\n\n return $serviceMock;\n }\n\n private function mockQueryBuilderAndHandler($userToSearch, $salesforceUsers): void\n {\n app()->bind(QueryBuilder::class, function () use ($userToSearch) {\n $queryBuilder = $this->createMock(QueryBuilder::class);\n $queryBuilder->expects($this->once())\n ->method('buildGetUsersQuery')\n ->with($userToSearch)\n ->willReturn('SELECT * FROM Users');\n\n return $queryBuilder;\n });\n\n $queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults($salesforceUsers, count($salesforceUsers), true, null));\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('query')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n }\n\n private function mockTeamRepository(\n Team $team,\n array $salesforceUser,\n ?User $user = null,\n bool $userSearch = true,\n array $customRules = []\n ): void {\n $teamRepository = $this->getMockForAbstractClass(TeamRepository::class, [], '', false, true, true, ['findActiveTeamMemberByEmail', 'getTeamSetting']);\n\n if ($userSearch) {\n $teamRepository->expects($this->once())\n ->method('findActiveTeamMemberByEmail')\n ->with($team, $salesforceUser['Email'])\n ->willReturn($user);\n }\n\n $teamSettings = $this->createMock(TeamSettings::class);\n $teamSettings->method('getValueType')\n ->willReturn('array');\n\n $teamSettings->method('getValue')\n ->willReturn(json_encode($customRules));\n\n $teamRepository->expects($this->once())\n ->method('getTeamSetting')\n ->with($team, 'custom_profile_validation')\n ->willReturn($teamSettings);\n\n app()->bind(TeamRepository::class, function () use ($teamRepository) {\n return $teamRepository;\n });\n }\n\n private function mockProfileRepository(User $user): void\n {\n $profileRepository = $this->createMock(ProfileRepository::class);\n $profileRepository->expects($this->once())\n ->method('updateOrCreateProfile')\n ->with(\n $user,\n [\n 'crm_configuration_id' => 1,\n 'crm_provider_id' => '456',\n ],\n [\n 'user_id' => 123,\n 'edition' => Profile::EDITION_LIGHTNING,\n 'has_external_cti' => true,\n 'crm_profile_id' => '789',\n ]\n )\n ->willReturn(new Profile());\n\n $this->app->instance(ProfileRepository::class, $profileRepository);\n }\n\n public function testBuildEnhancedNoteDecodesHtmlEntities(): void\n {\n $service = $this->getServiceMock(['createRecord']);\n\n $profile = new Profile();\n $profile->setAttribute('crm_provider_id', 'owner-123');\n $service->profile = $profile;\n\n $service->expects($this->exactly(2))\n ->method('createRecord')\n ->willReturnOnConsecutiveCalls('note-id-123', 'link-id-456');\n\n $bodyWithEntities = 'Welch's current challenges and Facebook's Club';\n\n $result = $this->invokePrivateMethod('buildEnhancedNote', $service, [\n 'Test Title',\n $bodyWithEntities,\n 'object-id-789',\n ]);\n\n $this->assertEquals('note-id-123', $result);\n }\n\n public function testBuildEnhancedNoteSanitizesWithoutQuotes(): void\n {\n $service = $this->getServiceMock(['createRecord']);\n\n $profile = new Profile();\n $profile->setAttribute('crm_provider_id', 'owner-456');\n $service->profile = $profile;\n\n $service->expects($this->exactly(2))\n ->method('createRecord')\n ->willReturnCallback(function ($type, $data) {\n if ($type === 'ContentNote') {\n $decoded = base64_decode($data['Content']);\n $this->assertStringContainsString(\"Welch's\", $decoded);\n $this->assertStringNotContainsString(''', $decoded);\n $this->assertStringNotContainsString('&#039;', $decoded);\n $this->assertStringContainsString('<script>', $decoded);\n\n return 'note-id-456';\n }\n\n return 'link-id-789';\n });\n\n $bodyWithMixedContent = \"Welch's and <script>alert('xss')</script>\";\n\n $result = $this->invokePrivateMethod('buildEnhancedNote', $service, [\n 'Test Title',\n $bodyWithMixedContent,\n 'object-id-123',\n ]);\n\n $this->assertEquals('note-id-456', $result);\n }\n\n public function testBuildEnhancedNoteConvertsLineBreaks(): void\n {\n $service = $this->getServiceMock(['createRecord']);\n\n $profile = new Profile();\n $profile->setAttribute('crm_provider_id', 'owner-789');\n $service->profile = $profile;\n\n $service->expects($this->exactly(2))\n ->method('createRecord')\n ->willReturnCallback(function ($type, $data) {\n if ($type === 'ContentNote') {\n $decoded = base64_decode($data['Content']);\n $this->assertStringContainsString('<br>', $decoded);\n $this->assertStringNotContainsString('<br />', $decoded);\n\n return 'note-id-789';\n }\n\n return 'link-id-012';\n });\n\n $bodyWithLineBreaks = \"Line 1\\nLine 2\\nLine 3\";\n\n $result = $this->invokePrivateMethod('buildEnhancedNote', $service, [\n 'Test Title',\n $bodyWithLineBreaks,\n 'object-id-456',\n ]);\n\n $this->assertEquals('note-id-789', $result);\n }\n\n public function testBuildEnhancedNoteHandlesComplexScenario(): void\n {\n $service = $this->getServiceMock(['createRecord']);\n\n $profile = new Profile();\n $profile->setAttribute('crm_provider_id', 'owner-complex');\n $service->profile = $profile;\n\n $service->expects($this->exactly(2))\n ->method('createRecord')\n ->willReturnCallback(function ($type, $data) {\n if ($type === 'ContentNote') {\n $decoded = base64_decode($data['Content']);\n\n $this->assertStringContainsString(\"Welch's\", $decoded);\n $this->assertStringContainsString(\"Facebook's Club\", $decoded);\n $this->assertStringContainsString(\"Arctics'\", $decoded);\n $this->assertStringNotContainsString(''', $decoded);\n $this->assertStringNotContainsString('&#039;', $decoded);\n $this->assertStringContainsString('<br>', $decoded);\n $this->assertStringContainsString('<', $decoded);\n $this->assertStringContainsString('>', $decoded);\n\n return 'note-complex';\n }\n\n return 'link-complex';\n });\n\n $complexBody = \"Summary:\\n---------\\nThe call focused on understanding Welch's current challenges and exploring how Arctics' Revenue Growth Management solutions could support their strategic goals.\\n\\n• John SMith discussed his role as a category advisor for Google and Facebook's Club, emphasizing the importance of market research and advising on product assortment.\\n• Madona introduced Arctics' Virtual Shoppers AI, which simulates consumer <behavior> to optimize pricing and promotional strategies.\";\n\n $result = $this->invokePrivateMethod('buildEnhancedNote', $service, [\n 'Jiminny Transcription Summary',\n $complexBody,\n 'task-id-001',\n ]);\n\n $this->assertEquals('note-complex', $result);\n }\n\n public function testSyncRemotelyDeletedObjectsWithErrorHandlingSuccess(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['syncRemotelyDeletedObjects'])\n ->getMock();\n\n // Mock team\n $team = $this->createMock(Team::class);\n $team->method('getUuid')->willReturn('team-uuid-123');\n $serviceMock->team = $team;\n\n // Expect syncRemotelyDeletedObjects to be called once and succeed\n $serviceMock->expects($this->once())\n ->method('syncRemotelyDeletedObjects')\n ->with(\\Jiminny\\Enums\\CrmObject::ACCOUNT);\n\n // Call the protected method using reflection\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');\n $method->setAccessible(true);\n\n // Should not throw any exceptions\n $method->invoke($serviceMock, \\Jiminny\\Enums\\CrmObject::ACCOUNT);\n\n $this->assertTrue(true); // Test completed successfully\n }\n\n public function testSyncRemotelyDeletedObjectsWithErrorHandlingFailure(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['syncRemotelyDeletedObjects'])\n ->getMock();\n\n // Mock team\n $team = $this->createMock(Team::class);\n $team->method('getUuid')->willReturn('team-uuid-456');\n $serviceMock->team = $team;\n\n // Mock logger to verify warning is logged\n $logger = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n\n // Use reflection to set the protected logger property\n $reflection = new \\ReflectionClass($serviceMock);\n $loggerProperty = $reflection->getProperty('logger');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($serviceMock, $logger);\n\n $exception = new \\Exception('Sync failed due to API error');\n\n // Expect syncRemotelyDeletedObjects to throw an exception\n $serviceMock->expects($this->once())\n ->method('syncRemotelyDeletedObjects')\n ->with(\\Jiminny\\Enums\\CrmObject::CONTACT)\n ->willThrowException($exception);\n\n // Expect warning to be logged with correct message and parameters\n $logger->expects($this->once())\n ->method('warning')\n ->with(\n '[Salesforce] Remotely deleted objects sync failed',\n [\n 'objectType' => 'contact',\n 'teamId' => 'team-uuid-456',\n 'reason' => 'Sync failed due to API error',\n ]\n );\n\n // Call the protected method using reflection\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');\n $method->setAccessible(true);\n\n // Should not re-throw the exception, just log it\n $method->invoke($serviceMock, \\Jiminny\\Enums\\CrmObject::CONTACT);\n\n $this->assertTrue(true); // Test completed successfully\n }\n\n public function testSyncRemotelyDeletedObjectsWithErrorHandlingWithLogParams(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['syncRemotelyDeletedObjects'])\n ->getMock();\n\n // Mock team\n $team = $this->createMock(Team::class);\n $team->method('getUuid')->willReturn('team-uuid-789');\n $serviceMock->team = $team;\n\n // Mock logger to verify warning is logged\n $logger = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n\n // Use reflection to set the protected logger property\n $loggerReflection = new \\ReflectionClass($serviceMock);\n $loggerProperty = $loggerReflection->getProperty('logger');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($serviceMock, $logger);\n\n $exception = new \\Exception('Network timeout');\n\n // Expect syncRemotelyDeletedObjects to throw an exception\n $serviceMock->expects($this->once())\n ->method('syncRemotelyDeletedObjects')\n ->with(\\Jiminny\\Enums\\CrmObject::OPPORTUNITY)\n ->willThrowException($exception);\n\n // Additional log parameters\n $logParams = [\n 'syncType' => 'full',\n 'batchSize' => 100,\n ];\n\n // Expect warning to be logged with merged parameters\n $logger->expects($this->once())\n ->method('warning')\n ->with(\n '[Salesforce] Remotely deleted objects sync failed',\n [\n 'objectType' => 'opportunity',\n 'teamId' => 'team-uuid-789',\n 'reason' => 'Network timeout',\n 'syncType' => 'full',\n 'batchSize' => 100,\n ]\n );\n\n // Call the protected method using reflection\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');\n $method->setAccessible(true);\n\n // Should not re-throw the exception, just log it\n $method->invoke($serviceMock, \\Jiminny\\Enums\\CrmObject::OPPORTUNITY, $logParams);\n\n $this->assertTrue(true); // Test completed successfully\n }\n\n /**\n * @dataProvider crmObjectProvider\n */\n public function testSyncRemotelyDeletedObjectsWithErrorHandlingDifferentCrmObjects(\\Jiminny\\Enums\\CrmObject $crmObject): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['syncRemotelyDeletedObjects'])\n ->getMock();\n\n // Mock team\n $team = $this->createMock(Team::class);\n $team->method('getUuid')->willReturn('team-uuid-test');\n $serviceMock->team = $team;\n\n // Mock logger to verify warning is logged\n $logger = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n\n // Use reflection to set the protected logger property\n $loggerReflectionClass = new \\ReflectionClass($serviceMock);\n $loggerProperty = $loggerReflectionClass->getProperty('logger');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($serviceMock, $logger);\n\n $exception = new \\Exception('Test error');\n\n // Expect syncRemotelyDeletedObjects to throw an exception\n $serviceMock->expects($this->once())\n ->method('syncRemotelyDeletedObjects')\n ->with($crmObject)\n ->willThrowException($exception);\n\n // Expect warning to be logged with correct entity type\n $logger->expects($this->once())\n ->method('warning')\n ->with(\n '[Salesforce] Remotely deleted objects sync failed',\n [\n 'objectType' => $crmObject->value,\n 'teamId' => 'team-uuid-test',\n 'reason' => 'Test error',\n ]\n );\n\n // Call the protected method using reflection\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');\n $method->setAccessible(true);\n\n $method->invoke($serviceMock, $crmObject);\n\n $this->assertTrue(true); // Test completed successfully\n }\n\n public function testHandleObjectDeletionWithDeletedEntity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['deleteCrmObject'])\n ->getMock();\n\n $entity = $this->createMock(\\Jiminny\\Models\\Account::class);\n $crmData = ['IsDeleted' => true];\n\n $serviceMock->expects($this->once())\n ->method('deleteCrmObject')\n ->with($entity);\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('handleObjectDeletion');\n $method->setAccessible(true);\n\n $method->invoke($serviceMock, $entity, $crmData);\n }\n\n public function testHandleObjectDeletionWithNonDeletedEntity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['deleteCrmObject'])\n ->getMock();\n\n $entity = $this->createMock(\\Jiminny\\Models\\Contact::class);\n $crmData = ['IsDeleted' => false];\n\n $serviceMock->expects($this->never())\n ->method('deleteCrmObject');\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('handleObjectDeletion');\n $method->setAccessible(true);\n\n $method->invoke($serviceMock, $entity, $crmData);\n }\n\n public function testDeleteCrmObjectWithValidEntity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['dispatchDeleteCrmObjectJob'])\n ->getMock();\n\n $entity = $this->createMock(\\Jiminny\\Models\\Lead::class);\n $entity->expects($this->once())->method('delete');\n\n $serviceMock->expects($this->once())\n ->method('dispatchDeleteCrmObjectJob')\n ->with($entity);\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('deleteCrmObject');\n $method->setAccessible(true);\n\n $method->invoke($serviceMock, $entity);\n }\n\n public function testDeleteCrmObjectWithNullEntity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['dispatchDeleteCrmObjectJob'])\n ->getMock();\n\n $serviceMock->expects($this->never())\n ->method('dispatchDeleteCrmObjectJob');\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('deleteCrmObject');\n $method->setAccessible(true);\n\n $method->invoke($serviceMock, null);\n }\n\n public function testDispatchDeleteCrmObjectJobWithNullEntity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($service);\n $method = $reflection->getMethod('dispatchDeleteCrmObjectJob');\n $method->setAccessible(true);\n\n // Should return early without dispatching - no exception expected\n $method->invoke($service, null);\n\n $this->assertTrue(true); // Test completed successfully\n }\n\n public function testDispatchDeleteCrmObjectJobWithUnsupportedEntity(): void\n {\n $this->expectException(\\TypeError::class);\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n $unsupportedEntity = $this->createMock(\\stdClass::class);\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($service);\n $method = $reflection->getMethod('dispatchDeleteCrmObjectJob');\n\n // This will throw TypeError due to union type constraint\n $method->invoke($service, $unsupportedEntity);\n }\n\n public function testHandleEntityDeletionByProviderIdMethodExists(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n // Test that the method exists and is accessible via reflection\n $reflection = new \\ReflectionClass($service);\n $method = $reflection->getMethod('handleEntityDeletionByProviderId');\n $method->setAccessible(true);\n\n // Verify method exists and has correct parameters\n $this->assertTrue($method->isProtected());\n $this->assertEquals(2, $method->getNumberOfParameters());\n\n $parameters = $method->getParameters();\n $this->assertEquals('targetEntity', $parameters[0]->getName());\n $this->assertEquals('crmData', $parameters[1]->getName());\n }\n\n public function testSyncRemotelyDeletedObjectsWithNoResults(): void\n {\n // Create a real service instance to avoid mock property issues\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n // Mock queryHandler to throw NoResultsException\n $queryHandler = $this->createMock(QueryHandler::class);\n $queryHandler->expects($this->once())\n ->method('queryDeleted')\n ->with('Opportunity')\n ->willThrowException(new NoResultsException('No results'));\n\n // Set the queryHandler using reflection on the real service\n $reflection = new \\ReflectionClass($service);\n $queryHandlerProperty = $reflection->getProperty('queryHandler');\n $queryHandlerProperty->setAccessible(true);\n $queryHandlerProperty->setValue($service, $queryHandler);\n\n $result = self::invokePrivateMethod('syncRemotelyDeletedObjects', $service, [CrmObject::OPPORTUNITY]);\n\n $this->assertFalse($result);\n }\n\n public function testSyncRemotelyDeletedObjectsWithEmptyResults(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n // Mock queryHandler to return empty results\n $queryResult = $this->createMock(QueryResults::class);\n $queryResult->method('getResults')->willReturn([]);\n\n $queryHandler = $this->createMock(QueryHandler::class);\n $queryHandler->expects($this->once())\n ->method('queryDeleted')\n ->with('Opportunity')\n ->willReturn($queryResult);\n\n // Set the queryHandler using reflection on the real service\n $reflection = new \\ReflectionClass($service);\n $queryHandlerProperty = $reflection->getProperty('queryHandler');\n $queryHandlerProperty->setAccessible(true);\n $queryHandlerProperty->setValue($service, $queryHandler);\n\n $result = self::invokePrivateMethod('syncRemotelyDeletedObjects', $service, [CrmObject::OPPORTUNITY]);\n\n $this->assertFalse($result);\n }\n\n public function testSyncRemotelyDeletedObjectsWithUnsupportedCrmObject(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n // Mock queryHandler to return some deleted objects so we reach the match statement\n $deletedObjects = [\n ['id' => 'task1'],\n ['id' => 'task2'],\n ];\n $queryResult = $this->createMock(QueryResults::class);\n $queryResult->method('getResults')->willReturn($deletedObjects);\n\n $queryHandler = $this->createMock(QueryHandler::class);\n $queryHandler->expects($this->once())\n ->method('queryDeleted')\n ->with('Task') // ucfirst('task') = 'Task'\n ->willReturn($queryResult);\n\n self::setPrivateProperty($service, 'queryHandler', $queryHandler);\n\n $this->expectException(InvalidArgumentException::class);\n $this->expectExceptionMessage('Unsupported CrmObject: task');\n\n self::invokePrivateMethod('syncRemotelyDeletedObjects', $service, [CrmObject::TASK]);\n }\n\n public static function crmObjectProvider(): array\n {\n return [\n 'Account' => [CrmObject::ACCOUNT],\n 'Contact' => [CrmObject::CONTACT],\n 'Lead' => [CrmObject::LEAD],\n 'Opportunity' => [CrmObject::OPPORTUNITY],\n ];\n }\n\n public function testVerifyTaskExistsReturnsTrue(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-123', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-123');\n $activity->method('getId')->willReturn(456);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-123', ['Id', 'IsDeleted'])\n ->willReturn(['Id' => 'task-123', 'IsDeleted' => false]);\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertTrue($result);\n }\n\n public function testVerifyTaskExistsReturnsTrueForEvent(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:event-123', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('event-123');\n $activity->method('getId')->willReturn(456);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_EVENT);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Event', 'event-123', ['Id', 'IsDeleted'])\n ->willReturn(['Id' => 'event-123', 'IsDeleted' => false]);\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertTrue($result);\n }\n\n public function testVerifyTaskExistsReturnsFalseWhenDeleted(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-456', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-456');\n $activity->method('getId')->willReturn(789);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-456', ['Id', 'IsDeleted'])\n ->willReturn(['Id' => 'task-456', 'IsDeleted' => true]);\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertFalse($result);\n }\n\n public function testVerifyTaskExistsReturnsFalseWhenNotFound(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-999', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-999');\n $activity->method('getId')->willReturn(999);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-999', ['Id', 'IsDeleted'])\n ->willThrowException(new \\Jiminny\\Exceptions\\HttpNotFoundException('Task not found'));\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertFalse($result);\n }\n\n public function testVerifyTaskExistsReturnsFalseWhenNoPlaybook(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-no-playbook', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-no-playbook');\n $activity->method('getId')->willReturn(111);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn(null);\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertFalse($result);\n }\n\n public function testVerifyTaskExistsThrowsExceptionForTransientErrors(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-error', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-error');\n $activity->method('getId')->willReturn(888);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-error', ['Id', 'IsDeleted'])\n ->willThrowException(new \\RuntimeException('Network timeout'));\n\n $this->expectException(\\RuntimeException::class);\n $this->expectExceptionMessage('Network timeout');\n\n $service->verifyTaskExists($activity);\n }\n\n public function testVerifyTaskExistsCachesResults(): void\n {\n $cachedValue = null;\n Cache::shouldReceive('remember')\n ->twice()\n ->with('crm_task_exists:123:task-cached', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(function ($key, $ttl, $callback) use (&$cachedValue) {\n if ($cachedValue === null) {\n $cachedValue = $callback();\n }\n\n return $cachedValue;\n });\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-cached');\n $activity->method('getId')->willReturn(555);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-cached', ['Id', 'IsDeleted'])\n ->willReturn(['Id' => 'task-cached', 'IsDeleted' => false]);\n\n $result1 = $service->verifyTaskExists($activity);\n $result2 = $service->verifyTaskExists($activity);\n\n $this->assertTrue($result1);\n $this->assertTrue($result2);\n }\n\n public function testVerifyTaskExistsReturnsFalseForHttpBadRequestException(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-400', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-400');\n $activity->method('getId')->willReturn(400);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-400', ['Id', 'IsDeleted'])\n ->willThrowException(new \\Jiminny\\Exceptions\\HttpBadRequestException('Bad request'));\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertFalse($result);\n }\n\n public function testImportOpportunitySkipsWhenNoProfileAndNoAccount(): void\n {\n $crmData = [\n 'Id' => 'SF-NO-USER-1',\n 'Name' => 'Test Opportunity',\n 'OwnerId' => 'owner-no-profile',\n // No AccountId\n ];\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(\\Illuminate\\Events\\Dispatcher::class); // ← ADD THIS\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n\n $service = new Service(\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService\n );\n\n $config = $this->createMock(Configuration::class);\n\n // Mock profiles relation returning null (no profile found)\n $profilesRelation = \\Mockery::mock(\\Illuminate\\Database\\Eloquent\\Relations\\HasMany::class);\n $profilesRelation->shouldReceive('where')->with('crm_provider_id', 'owner-no-profile')->andReturnSelf();\n $profilesRelation->shouldReceive('first')->andReturn(null);\n\n $config->expects($this->once())\n ->method('profiles')\n ->willReturn($profilesRelation);\n\n $team = $this->createMock(Team::class);\n $team->method('getId')->willReturn(1);\n\n $logger = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n $logger->expects($this->once())\n ->method('error')\n ->with(\n '[Salesforce] | Skip import, no user_id found',\n ['id' => 'SF-NO-USER-1']\n );\n\n $reflection = new \\ReflectionClass($service);\n\n $configProperty = $reflection->getProperty('config');\n $configProperty->setAccessible(true);\n $configProperty->setValue($service, $config);\n\n $teamProperty = $reflection->getProperty('team');\n $teamProperty->setAccessible(true);\n $teamProperty->setValue($service, $team);\n\n $loggerProperty = $reflection->getProperty('logger');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($service, $logger);\n\n // Initialize profile property to avoid \"must not be accessed before initialization\" error\n $profileProperty = $reflection->getProperty('profile');\n $profileProperty->setAccessible(true);\n $profileProperty->setValue($service, null);\n\n $result = self::invokePrivateMethod('importOpportunity', $service, [$crmData]);\n\n $this->assertNull($result);\n }\n\n public function testImportOpportunitySkipsWhenProfileNotFound(): void\n {\n $crmData = [\n 'Id' => 'SF-NO-USER-2',\n 'Name' => 'Test Opportunity',\n 'OwnerId' => 'owner-not-found',\n // No AccountId - avoid complex account processing\n ];\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $eventDispatcher = $this->createMock(\\Illuminate\\Events\\Dispatcher::class); // ← ADD THIS\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n\n\n $service = new Service(\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService\n );\n\n $config = $this->createMock(Configuration::class);\n\n // Mock profiles relation returning null (no profile found)\n $profilesRelation = \\Mockery::mock(\\Illuminate\\Database\\Eloquent\\Relations\\HasMany::class);\n $profilesRelation->shouldReceive('where')->with('crm_provider_id', 'owner-not-found')->andReturnSelf();\n $profilesRelation->shouldReceive('first')->andReturn(null);\n\n $config->expects($this->once())\n ->method('profiles')\n ->willReturn($profilesRelation);\n\n $team = $this->createMock(Team::class);\n $team->method('getId')->willReturn(1);\n\n $logger = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n $logger->expects($this->once())\n ->method('error')\n ->with(\n '[Salesforce] | Skip import, no user_id found',\n ['id' => 'SF-NO-USER-2']\n );\n\n $reflection = new \\ReflectionClass($service);\n\n $configProperty = $reflection->getProperty('config');\n $configProperty->setAccessible(true);\n $configProperty->setValue($service, $config);\n\n $teamProperty = $reflection->getProperty('team');\n $teamProperty->setAccessible(true);\n $teamProperty->setValue($service, $team);\n\n $loggerProperty = $reflection->getProperty('logger');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($service, $logger);\n\n // Initialize profile property\n $profileProperty = $reflection->getProperty('profile');\n $profileProperty->setAccessible(true);\n $profileProperty->setValue($service, null);\n\n $result = self::invokePrivateMethod('importOpportunity', $service, [$crmData]);\n\n $this->assertNull($result);\n }\n}\n\nclass HasManyExtended extends HasMany\n{\n public function where()\n {\n }\n\n public function first()\n {\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"A","depth":4,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":5,"bounds":{"left":0.0,"top":0.0,"width":0.008333334,"height":0.03},"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Select All","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"320","depth":6,"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"320","depth":7,"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"5772","depth":8,"bounds":{"left":0.0,"top":0.0,"width":0.2048611,"height":0.024444444},"on_screen":false,"value":"5772","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"46ea38f7-c940-395d-9c79-0329d30e9ab7","depth":6,"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"46ea38f7-c940-395d-9c79-0329d30e9ab7","depth":7,"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"2024-11-26 09:21:15","depth":8,"bounds":{"left":0.0,"top":0.0,"width":0.2048611,"height":0.024444444},"on_screen":false,"value":"2024-11-26 09:21:15","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"401","depth":6,"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"401","depth":7,"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"5772","depth":8,"bounds":{"left":0.0,"top":0.0,"width":0.2048611,"height":0.024444444},"on_screen":false,"value":"5772","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"<null>","depth":6,"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"<null>","depth":7,"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"5772","depth":8,"bounds":{"left":0.0,"top":0.0,"width":0.2048611,"height":0.024444444},"on_screen":false,"value":"5772","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"salesforce","depth":6,"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"salesforce","depth":7,"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"2024-11-26 09:21:15","depth":8,"bounds":{"left":0.0,"top":0.0,"width":0.2048611,"height":0.024444444},"on_screen":false,"value":"2024-11-26 09:21:15","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"enterprise","depth":6,"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"enterprise","depth":7,"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"2024-11-26 09:21:15","depth":8,"bounds":{"left":0.0,"top":0.0,"width":0.2048611,"height":0.024444444},"on_screen":false,"value":"2024-11-26 09:21:15","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"SWE38","depth":6,"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"SWE38","depth":7,"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"2024-11-26 09:21:15","depth":8,"bounds":{"left":0.0,"top":0.0,"width":0.2048611,"height":0.024444444},"on_screen":false,"value":"2024-11-26 09:21:15","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"0","depth":6,"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"0","depth":7,"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"5772","depth":8,"bounds":{"left":0.0,"top":0.0,"width":0.2048611,"height":0.024444444},"on_screen":false,"value":"5772","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"50.0","depth":6,"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"50.0","depth":7,"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"2024-11-26 09:21:15","depth":8,"bounds":{"left":0.0,"top":0.0,"width":0.2048611,"height":0.024444444},"on_screen":false,"value":"2024-11-26 09:21:15","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"01801","depth":6,"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"01801","depth":7,"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"2024-11-26 09:21:15","depth":8,"bounds":{"left":0.0,"top":0.0,"width":0.2048611,"height":0.024444444},"on_screen":false,"value":"2024-11-26 09:21:15","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"1","depth":6,"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"1","depth":7,"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"5772","depth":8,"bounds":{"left":0.0,"top":0.0,"width":0.2048611,"height":0.024444444},"on_screen":false,"value":"5772","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"1","depth":6,"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"1","depth":7,"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"5772","depth":8,"bounds":{"left":0.0,"top":0.0,"width":0.2048611,"height":0.024444444},"on_screen":false,"value":"5772","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"1","depth":6,"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"1","depth":7,"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"5772","depth":8,"bounds":{"left":0.0,"top":0.0,"width":0.2048611,"height":0.024444444},"on_screen":false,"value":"5772","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"00D20000000MdbgEAC","depth":6,"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"00D20000000MdbgEAC","depth":7,"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"2024-11-26 09:21:15","depth":8,"bounds":{"left":0.0,"top":0.0,"width":0.2048611,"height":0.024444444},"on_screen":false,"value":"2024-11-26 09:21:15","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"https://bipsolutions.my.salesforce.com","depth":6,"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"https://bipsolutions.my.salesforce.com","depth":7,"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"2024-11-26 09:21:15","depth":8,"bounds":{"left":0.0,"top":0.0,"width":0.2048611,"height":0.024444444},"on_screen":false,"value":"2024-11-26 09:21:15","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"2026-03-19 08:19:24","depth":6,"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"2026-03-19 08:19:24","depth":7,"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"2024-11-26 09:21:15","depth":8,"bounds":{"left":0.0,"top":0.0,"width":0.2048611,"height":0.024444444},"on_screen":false,"value":"2024-11-26 09:21:15","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"2026-05-22 09:48:21","depth":6,"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"2026-05-22 09:48:21","depth":7,"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"2024-11-26 09:21:15","depth":8,"bounds":{"left":0.0,"top":0.0,"width":0.2048611,"height":0.024444444},"on_screen":false,"value":"2024-11-26 09:21:15","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"2026-05-22 09:48:31","depth":6,"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"2026-05-22 09:48:31","depth":7,"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"2024-11-26 09:21:15","depth":8,"bounds":{"left":0.0,"top":0.0,"width":0.2048611,"height":0.024444444},"on_screen":false,"value":"2024-11-26 09:21:15","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"2026-05-22 09:48:43","depth":6,"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"2026-05-22 09:48:43","depth":7,"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"2024-11-26 09:21:15","depth":8,"bounds":{"left":0.0,"top":0.0,"width":0.2048611,"height":0.024444444},"on_screen":false,"value":"2024-11-26 09:21:15","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"2026-05-22 09:48:29","depth":6,"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"2026-05-22 09:48:29","depth":7,"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"2024-11-26 09:21:15","depth":8,"bounds":{"left":0.0,"top":0.0,"width":0.2048611,"height":0.024444444},"on_screen":false,"value":"2024-11-26 09:21:15","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
7732332105235147404
|
-6520523286572803295
|
idle
|
accessibility
|
NULL
|
Project: faVsco.js, menu
JY-20963-fix-import-on-de Project: faVsco.js, menu
JY-20963-fix-import-on-deleted-entity, menu
Start Listening for PHP Debug Connections
ServiceTest
Rerun 'PHPUnit: ServiceTest'
Debug 'ServiceTest'
Stop 'ServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Built-in Preview
Chrome
Firefox
Safari
Sync Changes
Hide This Notification
Code changed:
Hide
4
32
168
1
28
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Tests\Unit\Services\Crm\Salesforce;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Events\Dispatcher;
use Illuminate\Support\Facades\Cache;
use Jiminny\Component\Country\CountriesMap;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Enums\CrmObject;
use Jiminny\Exceptions\InvalidArgumentException;
use Jiminny\Exceptions\NoResultsException;
use Jiminny\Integrations\PlaybookResolver;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Playbook;
use Jiminny\Models\Team;
use Jiminny\Models\TeamSettings;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\ContactRoleRepository;
use Jiminny\Repositories\Crm\FieldDataRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\Salesforce\Client;
use Jiminny\Services\Crm\Salesforce\PayloadBuilder;
use Jiminny\Services\Crm\Salesforce\QueryBuilder;
use Jiminny\Services\Crm\Salesforce\QueryHandler;
use Jiminny\Services\Crm\Salesforce\QueryIterator;
use Jiminny\Services\Crm\Salesforce\QueryResults;
use Jiminny\Services\Crm\Salesforce\Service;
use PHPUnit\Framework\MockObject\MockObject;
use Tests\TestCase;
use Tests\Unit\Traits\TestPrivateMethod;
class ServiceTest extends TestCase
{
use TestPrivateMethod;
public function testFetchAndAssociateRelatedActivity(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$payloadBuilder->method('addCustomLogicFieldsPayload')
->willReturnCallback(function ($activity, $payload) {
return $payload;
});
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods(['fetchRelatedActivity', 'getPlaybook', 'getPlaybookCategory', 'updateRecord'])
->getMock();
$serviceMock->expects($this->once())
->method('fetchRelatedActivity')
->willReturn([
'Id' => 'testId',
'Type' => null,
'OwnerId' => 'testerUser',
'Description' => 'Test description',
]);
$user = $this->createMock(User::class);
$team = $this->createMock(Team::class);
$user->method('getAttribute')->with('team')->willReturn($team);
$playbook = $this->createMock(Playbook::class);
$playbook->method('getActivityField')->willReturn(null);
$playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_EVENT);
$serviceMock->expects($this->once())
->method('getPlaybook')
->with($user)
->willReturn($playbook);
$serviceMock->expects($this->never())
->method('getPlaybookCategory');
$serviceMock->expects($this->never())
->method('updateRecord');
$fieldDataRepository = $this->createMock(FieldDataRepository::class);
$fieldDataRepository->method('getActivityFieldData')->willReturn(collect([]));
app()->instance(FieldDataRepository::class, $fieldDataRepository);
$config = $this->createMock(Configuration::class);
$profilesRelation = $this->getMockBuilder(\Illuminate\Database\Eloquent\Relations\HasMany::class)
->disableOriginalConstructor()
->onlyMethods(['get'])
->addMethods(['where'])
->getMock();
$profilesRelation->method('where')->willReturnSelf();
$profilesRelation->method('get')->willReturn(collect([]));
$config->method('profiles')->willReturn($profilesRelation);
$serviceMock->config = $config;
$serviceMock->profile = null;
$actualStartTime = \Carbon\Carbon::now();
$activity = $this->getMockBuilder(Activity::class)
->disableOriginalConstructor()
->onlyMethods(['update', 'hasProspect'])
->getMock();
$activity->method('update')->willReturn(true);
$activity->method('hasProspect')->willReturn(true);
$activity->type = Activity::TYPE_CONFERENCE;
$activity->provider = Activity::PROVIDER_TWILIO;
$activity->lead_id = 1;
$activity->user_id = 0;
$activity->id_string = 'test-activity-id';
$activity->user = $user;
$activity->actual_start_time = $actualStartTime;
$activity->uuid = 'c53d8320-f556-4cee-a2f8-5f232f454ca4';
app()->bind(PlaybookResolver::class, function () use ($user) {
$playbook = $this->createMock(Playbook::class);
$playbookResolver = $this->createMock(PlaybookResolver::class);
$playbookResolver->expects($this->once())
->method('resolvePlaybookByUser')
->with($user)
->willReturn($playbook);
return $playbookResolver;
});
$data = $serviceMock->fetchAndAssociateRelatedActivity($activity);
$this->assertInstanceOf(Activity::class, $data);
$this->assertEquals(Activity::TYPE_CONFERENCE, $data->getType());
$this->assertEquals($actualStartTime->getTimestamp(), $data->getActualStartTime()->getTimestamp());
}
public function testFetchAndAssociateRelatedActivitySkipsForTaskBasedPlaybook(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods(['fetchRelatedActivity', 'getPlaybook'])
->getMock();
$user = $this->createMock(User::class);
$playbook = $this->createMock(Playbook::class);
$playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);
$playbook->method('getId')->willReturn(123);
$serviceMock->expects($this->once())
->method('getPlaybook')
->with($user)
->willReturn($playbook);
$serviceMock->expects($this->never())
->method('fetchRelatedActivity');
$activity = $this->getMockBuilder(Activity::class)
->disableOriginalConstructor()
->onlyMethods(['hasProspect', 'getUuid'])
->getMock();
$activity->method('hasProspect')->willReturn(true);
$activity->method('getUuid')->willReturn('c53d8320-f556-4cee-a2f8-5f232f454ca4');
$activity->type = Activity::TYPE_CONFERENCE;
$activity->actual_start_time = \Carbon\Carbon::now();
$activity->user = $user;
$result = $serviceMock->fetchAndAssociateRelatedActivity($activity);
$this->assertNull($result);
}
public function testFetchAndAssociateRelatedActivityReturnsNullForNonConference(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$serviceMock = new Service(
client: $client,
payloadBuilder: $payloadBuilder,
eventDispatcher: $eventDispatcher,
countriesMap: $countriesMap,
prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)
);
$activity = $this->getMockBuilder(Activity::class)
->disableOriginalConstructor()
->getMock();
$activity->type = Activity::TYPE_SOFTPHONE;
$result = $serviceMock->fetchAndAssociateRelatedActivity($activity);
$this->assertNull($result);
}
public function testFetchAndAssociateRelatedActivityReturnsNullWhenNoStartTime(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$serviceMock = new Service(
client: $client,
payloadBuilder: $payloadBuilder,
eventDispatcher: $eventDispatcher,
countriesMap: $countriesMap,
prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)
);
$activity = $this->getMockBuilder(Activity::class)
->disableOriginalConstructor()
->getMock();
$activity->type = Activity::TYPE_CONFERENCE;
$activity->actual_start_time = null;
$activity->scheduled_start_time = null;
$result = $serviceMock->fetchAndAssociateRelatedActivity($activity);
$this->assertNull($result);
}
public function testFetchAndAssociateRelatedActivityReturnsNullWhenNoProspect(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods(['getPlaybook'])
->getMock();
$serviceMock->expects($this->never())
->method('getPlaybook');
$activity = $this->getMockBuilder(Activity::class)
->disableOriginalConstructor()
->onlyMethods(['hasProspect', 'getUuid'])
->getMock();
$activity->method('hasProspect')->willReturn(false);
$activity->method('getUuid')->willReturn('c53d8320-f556-4cee-a2f8-5f232f454ca4');
$activity->type = Activity::TYPE_CONFERENCE;
$activity->actual_start_time = \Carbon\Carbon::now();
$result = $serviceMock->fetchAndAssociateRelatedActivity($activity);
$this->assertNull($result);
}
public function testMatchExactlyByEmail(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods([])
->getMock();
$profile = new Profile();
$profile->setAttribute('id', bin2hex(random_bytes(8)));
$serviceMock->profile = $profile;
$team = $this->createMock(Team::class);
$serviceMock->team = $team;
$data = $serviceMock->matchExactlyByEmail(bin2hex(random_bytes(8)) . '[EMAIL]');
$this->assertEquals(null, $data);
}
public function testMatchDomainFromEmail(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$queryIterator = $this->createMock(QueryIterator::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$config = $this->createMock(Configuration::class);
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('search')
->willReturn($queryIterator);
return $handler;
});
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods(['convertCrmData'])
->getMock();
$profile = new Profile();
$profile->account_fields = 'Field1, Field2, Field3';
$serviceMock->profile = $profile;
$serviceMock->expects($this->once())
->method('convertCrmData')
->willReturn(['test']);
$this->app->bind(QueryBuilder::class, function () {
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->expects($this->once())
->method('buildMatchByDomainQuery')
->with('[EMAIL]')
->willReturn('FIND {[EMAIL]} IN ALL FIELDS RETURNING Account(Id)');
return $queryBuilder;
});
$team = $this->createMock(Team::class);
$serviceMock->team = $team;
$serviceMock->config = $config;
$data = $serviceMock->matchByDomain('[EMAIL]');
$this->assertEquals(['test'], $data);
}
public function testBuildTaskSearchFields(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$service = new Service(
client: $client,
payloadBuilder: $payloadBuilder,
eventDispatcher: $eventDispatcher,
countriesMap: $countriesMap,
prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)
);
$fields = $service->buildTaskSearchFields();
$expectedFields = ['Id', 'WhoId', 'WhatId', 'AccountId'];
$this->assertEquals($expectedFields, $fields);
}
public function testMapCrmObjects(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$service = new Service(
client: $client,
payloadBuilder: $payloadBuilder,
eventDispatcher: $eventDispatcher,
countriesMap: $countriesMap,
prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class),
);
$sampleTask = [
'WhoId' => '003sampleWhoId',
'AccountId' => 'sampleAccountId',
'WhatId' => 'sampleWhatId',
];
$activityData = $service->mapCrmObjects($sampleTask);
$expectedActivityData = [
'contact' => '003sampleWhoId',
'account' => 'sampleAccountId',
'opportunity' => 'sampleWhatId',
];
$this->assertEquals($expectedActivityData, $activityData);
}
public function testGetInstalledAppVersion(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$queryIterator = $this->createMock(QueryIterator::class);
$queryIterator->expects($this->any())
->method('current')->willReturn([
'SubscriberPackageVersion' => [
'MajorVersion' => '1',
'MinorVersion' => '0',
'PatchVersion' => '1',
'BuildNumber' => '0',
],
]);
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('metadata')
->willReturn($queryIterator);
return $handler;
});
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods(array_diff(get_class_methods(Service::class), ['getInstalledAppVersion']))
->getMock();
$version = $serviceMock->getInstalledAppVersion();
$this->assertEquals('1010', $version);
}
public function testSyncProfiles(): void
{
$userToSearch = null;
$team = $this->createMock(Team::class);
$config = $this->createMockedConfiguration();
$config->expects($this->once())
->method('getId')
->willReturn(1);
$salesforceUser = [
'Email' => '[EMAIL]',
'UserPreferencesLightningExperiencePreferred' => true,
'CallCenterId' => '123',
'Id' => '456',
'ProfileId' => '789',
];
app()->bind(QueryBuilder::class, function () use ($userToSearch) {
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->expects($this->once())
->method('buildGetUsersQuery')
->with($userToSearch)
->willReturn('SELECT * FROM Users');
return $queryBuilder;
});
$queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('query')
->with('SELECT * FROM Users')
->willReturn($queryIterator);
return $handler;
});
$user = $this->createMock(User::class);
$user->expects($this->once())
->method('getId')
->willReturn(123);
$this->mockTeamRepository($team, $salesforceUser, $user);
$profileRepository = $this->createMock(ProfileRepository::class);
$profileRepository->expects($this->once())
->method('updateOrCreateProfile')
->with(
$user,
[
'crm_configuration_id' => 1,
'crm_provider_id' => '456',
],
[
'user_id' => 123,
'edition' => Profile::EDITION_LIGHTNING,
'has_external_cti' => true,
'crm_profile_id' => '789',
]
)
->willReturn(new Profile());
$this->app->instance(ProfileRepository::class, $profileRepository);
$serviceMock = $this->getServiceMock();
$serviceMock->team = $team;
$serviceMock->config = $config;
$result = $serviceMock->syncProfiles($userToSearch);
$this->assertNull($result);
}
public function testSyncProfilesEmailIsNull(): void
{
$userToSearch = $this->createMock(User::class);
$salesforceUser = [
'Email' => null,
];
app()->bind(QueryBuilder::class, function () {
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->expects($this->once())
->method('buildGetUsersQuery')
->with(null)
->willReturn('SELECT * FROM Users');
return $queryBuilder;
});
$queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->exactly(2))
->method('query')
->willReturn($queryIterator);
return $handler;
});
$team = $this->createMock(Team::class);
$user = $this->createMock(User::class);
$this->mockTeamRepository($team, $salesforceUser, $user, false);
$config = $this->createMock(Configuration::class);
$serviceMock = $this->getServiceMock();
$serviceMock->team = $team;
$serviceMock->config = $config;
$profile = $serviceMock->syncProfiles(null);
$this->assertNull($profile);
}
public function testSyncProfilesUserToSearchMatchesCurrentUser(): void
{
$userToSearch = $this->createMock(User::class);
$userToSearch->expects($this->once())
->method('getId')
->willReturn(123);
$team = $this->createMock(Team::class);
$config = $this->createMock(Configuration::class);
$config->expects($this->once())
->method('getId')
->willReturn(1);
$salesforceUser = [
'Email' => '[EMAIL]',
'UserPreferencesLightningExperiencePreferred' => true,
'CallCenterId' => '123',
'Id' => '456',
'ProfileId' => '789',
];
app()->bind(QueryBuilder::class, function () use ($userToSearch) {
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->expects($this->once())
->method('buildGetUsersQuery')
->with($userToSearch)
->willReturn('SELECT * FROM Users');
return $queryBuilder;
});
$queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('query')
->with('SELECT * FROM Users')
->willReturn($queryIterator);
return $handler;
});
$user = $this->createMock(User::class);
$user->expects($this->exactly(2))
->method('getId')
->willReturn(123);
$this->mockTeamRepository($team, $salesforceUser, $user);
$profileRepository = $this->createMock(ProfileRepository::class);
$profileRepository->expects($this->once())
->method('updateOrCreateProfile')
->with(
$user,
[
'crm_configuration_id' => 1,
'crm_provider_id' => '456',
],
[
'user_id' => 123,
'edition' => Profile::EDITION_LIGHTNING,
'has_external_cti' => true,
'crm_profile_id' => '789',
]
)
->willReturn(new Profile());
$this->app->instance(ProfileRepository::class, $profileRepository);
$serviceMock = $this->getServiceMock();
$serviceMock->team = $team;
$serviceMock->config = $config;
$profile = $serviceMock->syncProfiles($userToSearch);
$this->assertInstanceOf(Profile::class, $profile);
}
public function testSyncProfilesWithCustomValidation(): void
{
$userToSearch = null;
$team = $this->createMock(Team::class);
$config = $this->createMockedConfiguration();
$config->expects($this->atLeastOnce()) // Changed from once() to atLeastOnce()
->method('getId')
->willReturn(1);
$salesforceUser = [
'Email' => '[EMAIL]',
'UserPreferencesLightningExperiencePreferred' => true,
'CallCenterId' => '123',
'Id' => '456',
'ProfileId' => '789',
'CustomField' => 'CustomValue',
];
$customRules = [
['field' => 'CustomField', 'value' => 'CustomValue'],
];
$this->mockQueryBuilderAndHandler($userToSearch, [$salesforceUser]);
$user = $this->createMock(User::class);
$user->expects($this->once())
->method('getId')
->willReturn(123);
$this->mockTeamRepository($team, $salesforceUser, $user, true, $customRules);
$this->mockProfileRepository($user);
$serviceMock = $this->getServiceMock();
$serviceMock->team = $team;
$serviceMock->config = $config;
$profile = $serviceMock->syncProfiles($userToSearch);
$this->assertNull($profile);
}
public function testSyncProfilesWithCustomValidationFailing(): void
{
$userToSearch = null;
$team = $this->createMock(Team::class);
$config = $this->createMockedConfiguration();
$salesforceUser = [
'Email' => '[EMAIL]',
'UserPreferencesLightningExperiencePreferred' => true,
'CallCenterId' => '123',
'Id' => '456',
'ProfileId' => '789',
'CustomField' => 'WrongValue',
];
$customRules = [
['field' => 'CustomField', 'value' => 'CustomValue'],
];
$this->mockQueryBuilderAndHandler($userToSearch, [$salesforceUser]);
$teamRepository = $this->getMockForAbstractClass(TeamRepository::class, [], '', false, true, true, ['findActiveTeamMemberByEmail', 'getTeamSetting']);
$teamSettings = $this->createMock(TeamSettings::class);
$teamSettings->method('getValueType')
->willReturn('array');
$teamSettings->method('getValue')
->willReturn(json_encode($customRules));
$teamRepository->expects($this->once())
->method('getTeamSetting')
->with($team, 'custom_profile_validation')
->willReturn($teamSettings);
app()->bind(TeamRepository::class, function () use ($teamRepository) {
return $teamRepository;
});
$profileRepository = $this->createMock(ProfileRepository::class);
$profileRepository->expects($this->never())
->method('updateOrCreateProfile');
$this->app->instance(ProfileRepository::class, $profileRepository);
$serviceMock = $this->getServiceMock();
$serviceMock->team = $team;
$serviceMock->config = $config;
$result = $serviceMock->syncProfiles($userToSearch);
$this->assertNull($result);
}
public function testGetContactRolesFromCrm(): void
{
$contactRoles = [
[
'Id' => '1',
'ContactId' => 'Contact1',
'OpportunityId' => 'Opportunity1',
'Opportunity' => ['OwnerId' => 'Owner1'],
'IsPrimary' => true,
'Role' => 'Decision Maker',
],
];
$expectedResponse = [
[
'id' => '1',
'contactId' => 'Contact1',
'opportunityId' => 'Opportunity1',
'ownerId' => 'Owner1',
'isPrimary' => true,
'role' => 'Decision Maker',
],
];
$this->bindQueryIterator($contactRoles);
$serviceMock = $this->getServiceMock();
$data = $serviceMock->getContactRolesFromCrm(now()->subDay());
$this->assertEquals($expectedResponse, $data);
}
public function testGetContactRolesFromCrmNoResult(): void
{
$this->bindQueryIterator([]);
$serviceMock = $this->getServiceMock();
$data = $serviceMock->getContactRolesFromCrm(now()->subDay());
$this->assertEquals([], $data);
}
public function testSyncContactRoles(): void
{
$contactRoles = [
[
'id' => '1',
'contactId' => 'Contact1',
'opportunityId' => 'Opportunity1',
'ownerId' => 'Owner1',
'isPrimary' => true,
'role' => 'Decision Maker',
],
];
app()->bind(ContactRoleRepository::class, function () {
$contactRoleRepository = $this->createMock(ContactRoleRepository::class);
$contactRoleRepository->expects($this->once())
->method('saveContactRoles');
return $contactRoleRepository;
});
$serviceMock = $this->getServiceMock([
'getContactRolesFromCrm',
'syncRemotelyDeletedContactRoles',
'syncContact',
'syncOpportunity',
]);
$config = $this->createMock(Configuration::class);
$hasMany = $this->createMock(HasManyExtended::class);
$hasMany->expects($this->exactly(2))
->method('where')
->willReturn($hasMany);
$hasMany->expects($this->exactly(2))
->method('first')
->willReturn(
$this->createMock(Contact::class),
$this->createMock(Opportunity::class)
);
$config->expects($this->once())
->method('contacts')
->willReturn($hasMany);
$config->expects($this->once())
->method('opportunities')
->willReturn($hasMany);
$serviceMock->config = $config;
$serviceMock->expects($this->once())
->method('getContactRolesFromCrm')
->willReturn($contactRoles);
$serviceMock->expects($this->once())
->method('syncRemotelyDeletedContactRoles');
$data = $serviceMock->syncContactRoles(now()->subDay());
$this->assertEquals(1, $data);
}
public function testSyncRemotelyDeletedContactRoles(): void
{
$contactRoles = [
[
'id' => '1',
'crm_provider_id' => '1',
],
];
app()->bind(QueryHandler::class, function () use ($contactRoles) {
$queryResults = new QueryResults($contactRoles, 1, true, null);
$handler = $this->createMock(QueryHandler::class);
$handler->method('queryDeleted')
->willReturn($queryResults);
return $handler;
});
app()->bind(ContactRoleRepository::class, function () {
$contactRoleRepository = $this->createMock(ContactRoleRepository::class);
$contactRoleRepository->expects($this->once())
->method('deleteContactRoles');
return $contactRoleRepository;
});
$serviceMock = $this->getServiceMock();
$serviceMock->team = $this->createMock(Team::class);
$data = $this->invokePrivateMethod('syncRemotelyDeletedContactRoles', $serviceMock, []);
$this->assertTrue($data);
}
private function bindQueryIterator(array $queryResult): void
{
/** @var Client $client */
$client = $this->createMock(Client::class);
$queryIterator = new QueryIterator(
$client,
new QueryResults($queryResult, 1, true, null)
);
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('query')
->willReturn($queryIterator);
return $handler;
});
}
public static function getOpportunitySortOrderDataProvider(): array
{
return [
'all open recently updated' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED, ['LastModifiedDate DESC', true]],
'all open recently created' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED, ['CreatedDate DESC', true]],
'all open oldest created' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED, ['CreatedDate ASC', true]],
'all recently updated' => [Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED, ['LastModifiedDate DESC', false]],
'default' => ['unknown', ['LastModifiedDate DESC', true]],
];
}
private function createMockedConfiguration(): Configuration
{
$config = $this->createMock(Configuration::class);
$profilesRelation = $this->getMockBuilder(\Illuminate\Database\Eloquent\Relations\HasMany::class)
->disableOriginalConstructor()
->onlyMethods(['get'])
->addMethods(['where', 'first'])
->getMock();
$profilesRelation->method('where')->willReturnSelf();
$profilesRelation->method('get')->willReturn(collect([]));
$profilesRelation->method('first')->willReturn(null);
$config->method('profiles')->willReturn($profilesRelation);
return $config;
}
private function getServiceMock(array $onlyMethods = []): MockObject&Service
{
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$this->createMock(Client::class),
$this->createMock(PayloadBuilder::class),
$this->createMock(Dispatcher::class),
$this->createMock(CountriesMap::class),
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods($onlyMethods)
->getMock();
$serviceMock->profile = $this->createMock(Profile::class);
return $serviceMock;
}
private function mockQueryBuilderAndHandler($userToSearch, $salesforceUsers): void
{
app()->bind(QueryBuilder::class, function () use ($userToSearch) {
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->expects($this->once())
->method('buildGetUsersQuery')
->with($userToSearch)
->willReturn('SELECT * FROM Users');
return $queryBuilder;
});
$queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults($salesforceUsers, count($salesforceUsers), true, null));
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('query')
->willReturn($queryIterator);
return $handler;
});
}
private function mockTeamRepository(
Team $team,
array $salesforceUser,
?User $user = null,
bool $userSearch = true,
array $customRules = []
): void {
$teamRepository = $this->getMockForAbstractClass(TeamRepository::class, [], '', false, true, true, ['findActiveTeamMemberByEmail', 'getTeamSetting']);
if ($userSearch) {
$teamRepository->expects($this->once())
->method('findActiveTeamMemberByEmail')
->with($team, $salesforceUser['Email'])
->willReturn($user);
}
$teamSettings = $this->createMock(TeamSettings::class);
$teamSettings->method('getValueType')
->willReturn('array');
$teamSettings->method('getValue')
->willReturn(json_encode($customRules));
$teamRepository->expects($this->once())
->method('getTeamSetting')
->with($team, 'custom_profile_validation')
->willReturn($teamSettings);
app()->bind(TeamRepository::class, function () use ($teamRepository) {
return $teamRepository;
});
}
private function mockProfileRepository(User $user): void
{
$profileRepository = $this->createMock(ProfileRepository::class);
$profileRepository->expects($this->once())
->method('updateOrCreateProfile')
->with(
$user,
[
'crm_configuration_id' => 1,
'crm_provider_id' => '456',
],
[
'user_id' => 123,
'edition' => Profile::EDITION_LIGHTNING,
'has_external_cti' => true,
'crm_profile_id' => '789',
]
)
->willReturn(new Profile());
$this->app->instance(ProfileRepository::class, $profileRepository);
}
public function testBuildEnhancedNoteDecodesHtmlEntities(): void
{
$service = $this->getServiceMock(['createRecord']);
$profile = new Profile();
$profile->setAttribute('crm_provider_id', 'owner-123');
$service->profile = $profile;
$service->expects($this->exactly(2))
->method('createRecord')
->willReturnOnConsecutiveCalls('note-id-123', 'link-id-456');
$bodyWithEntities = 'Welch's current challenges and Facebook's Club';
$result = $this->invokePrivateMethod('buildEnhancedNote', $service, [
'Test Title',
$bodyWithEntities,
'object-id-789',
]);
$this->assertEquals('note-id-123', $result);
}
public function testBuildEnhancedNoteSanitizesWithoutQuotes(): void
{
$service = $this->getServiceMock(['createRecord']);
$profile = new Profile();
$profile->setAttribute('crm_provider_id', 'owner-456');
$service->profile = $profile;
$service->expects($this->exactly(2))
->method('createRecord')
->willReturnCallback(function ($type, $data) {
if ($type === 'ContentNote') {
$decoded = base64_decode($data['Content']);
$this->assertStringContainsString("Welch's", $decoded);
$this->assertStringNotContainsString(''', $decoded);
$this->assertStringNotContainsString('&#039;', $decoded);
$this->assertStringContainsString('<script>', $decoded);
return 'note-id-456';
}
return 'link-id-789';
});
$bodyWithMixedContent = "Welch's and <script>alert('xss')</script>";
$result = $this->invokePrivateMethod('buildEnhancedNote', $service, [
'Test Title',
$bodyWithMixedContent,
'object-id-123',
]);
$this->assertEquals('note-id-456', $result);
}
public function testBuildEnhancedNoteConvertsLineBreaks(): void
{
$service = $this->getServiceMock(['createRecord']);
$profile = new Profile();
$profile->setAttribute('crm_provider_id', 'owner-789');
$service->profile = $profile;
$service->expects($this->exactly(2))
->method('createRecord')
->willReturnCallback(function ($type, $data) {
if ($type === 'ContentNote') {
$decoded = base64_decode($data['Content']);
$this->assertStringContainsString('<br>', $decoded);
$this->assertStringNotContainsString('<br />', $decoded);
return 'note-id-789';
}
return 'link-id-012';
});
$bodyWithLineBreaks = "Line 1\nLine 2\nLine 3";
$result = $this->invokePrivateMethod('buildEnhancedNote', $service, [
'Test Title',
$bodyWithLineBreaks,
'object-id-456',
]);
$this->assertEquals('note-id-789', $result);
}
public function testBuildEnhancedNoteHandlesComplexScenario(): void
{
$service = $this->getServiceMock(['createRecord']);
$profile = new Profile();
$profile->setAttribute('crm_provider_id', 'owner-complex');
$service->profile = $profile;
$service->expects($this->exactly(2))
->method('createRecord')
->willReturnCallback(function ($type, $data) {
if ($type === 'ContentNote') {
$decoded = base64_decode($data['Content']);
$this->assertStringContainsString("Welch's", $decoded);
$this->assertStringContainsString("Facebook's Club", $decoded);
$this->assertStringContainsString("Arctics'", $decoded);
$this->assertStringNotContainsString(''', $decoded);
$this->assertStringNotContainsString('&#039;', $decoded);
$this->assertStringContainsString('<br>', $decoded);
$this->assertStringContainsString('<', $decoded);
$this->assertStringContainsString('>', $decoded);
return 'note-complex';
}
return 'link-complex';
});
$complexBody = "Summary:\n---------\nThe call focused on understanding Welch's current challenges and exploring how Arctics' Revenue Growth Management solutions could support their strategic goals.\n\n• John SMith discussed his role as a category advisor for Google and Facebook's Club, emphasizing the importance of market research and advising on product assortment.\n• Madona introduced Arctics' Virtual Shoppers AI, which simulates consumer <behavior> to optimize pricing and promotional strategies.";
$result = $this->invokePrivateMethod('buildEnhancedNote', $service, [
'Jiminny Transcription Summary',
$complexBody,
'task-id-001',
]);
$this->assertEquals('note-complex', $result);
}
public function testSyncRemotelyDeletedObjectsWithErrorHandlingSuccess(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$prospectPhotoPathService,
])
->onlyMethods(['syncRemotelyDeletedObjects'])
->getMock();
// Mock team
$team = $this->createMock(Team::class);
$team->method('getUuid')->willReturn('team-uuid-123');
$serviceMock->team = $team;
// Expect syncRemotelyDeletedObjects to be called once and succeed
$serviceMock->expects($this->once())
->method('syncRemotelyDeletedObjects')
->with(\Jiminny\Enums\CrmObject::ACCOUNT);
// Call the protected method using reflection
$reflection = new \ReflectionClass($serviceMock);
$method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');
$method->setAccessible(true);
// Should not throw any exceptions
$method->invoke($serviceMock, \Jiminny\Enums\CrmObject::ACCOUNT);
$this->assertTrue(true); // Test completed successfully
}
public function testSyncRemotelyDeletedObjectsWithErrorHandlingFailure(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$prospectPhotoPathService,
])
->onlyMethods(['syncRemotelyDeletedObjects'])
->getMock();
// Mock team
$team = $this->createMock(Team::class);
$team->method('getUuid')->willReturn('team-uuid-456');
$serviceMock->team = $team;
// Mock logger to verify warning is logged
$logger = $this->createMock(\Psr\Log\LoggerInterface::class);
// Use reflection to set the protected logger property
$reflection = new \ReflectionClass($serviceMock);
$loggerProperty = $reflection->getProperty('logger');
$loggerProperty->setAccessible(true);
$loggerProperty->setValue($serviceMock, $logger);
$exception = new \Exception('Sync failed due to API error');
// Expect syncRemotelyDeletedObjects to throw an exception
$serviceMock->expects($this->once())
->method('syncRemotelyDeletedObjects')
->with(\Jiminny\Enums\CrmObject::CONTACT)
->willThrowException($exception);
// Expect warning to be logged with correct message and parameters
$logger->expects($this->once())
->method('warning')
->with(
'[Salesforce] Remotely deleted objects sync failed',
[
'objectType' => 'contact',
'teamId' => 'team-uuid-456',
'reason' => 'Sync failed due to API error',
]
);
// Call the protected method using reflection
$reflection = new \ReflectionClass($serviceMock);
$method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');
$method->setAccessible(true);
// Should not re-throw the exception, just log it
$method->invoke($serviceMock, \Jiminny\Enums\CrmObject::CONTACT);
$this->assertTrue(true); // Test completed successfully
}
public function testSyncRemotelyDeletedObjectsWithErrorHandlingWithLogParams(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$prospectPhotoPathService,
])
->onlyMethods(['syncRemotelyDeletedObjects'])
->getMock();
// Mock team
$team = $this->createMock(Team::class);
$team->method('getUuid')->willReturn('team-uuid-789');
$serviceMock->team = $team;
// Mock logger to verify warning is logged
$logger = $this->createMock(\Psr\Log\LoggerInterface::class);
// Use reflection to set the protected logger property
$loggerReflection = new \ReflectionClass($serviceMock);
$loggerProperty = $loggerReflection->getProperty('logger');
$loggerProperty->setAccessible(true);
$loggerProperty->setValue($serviceMock, $logger);
$exception = new \Exception('Network timeout');
// Expect syncRemotelyDeletedObjects to throw an exception
$serviceMock->expects($this->once())
->method('syncRemotelyDeletedObjects')
->with(\Jiminny\Enums\CrmObject::OPPORTUNITY)
->willThrowException($exception);
// Additional log parameters
$logParams = [
'syncType' => 'full',
'batchSize' => 100,
];
// Expect warning to be logged with merged parameters
$logger->expects($this->once())
->method('warning')
->with(
'[Salesforce] Remotely deleted objects sync failed',
[
'objectType' => 'opportunity',
'teamId' => 'team-uuid-789',
'reason' => 'Network timeout',
'syncType' => 'full',
'batchSize' => 100,
]
);
// Call the protected method using reflection
$reflection = new \ReflectionClass($serviceMock);
$method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');
...
|
70623
|
NULL
|
NULL
|
NULL
|
|
70628
|
2537
|
7
|
2026-05-22T10:25:06.882008+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779445506882_m2.jpg...
|
PhpStorm
|
faVsco.js – ServiceTest.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20963-fix-import-on-de Project: faVsco.js, menu
JY-20963-fix-import-on-deleted-entity, menu
Start Listening for PHP Debug Connections
ServiceTest
Rerun 'PHPUnit: ServiceTest'
Debug 'ServiceTest'
Stop 'ServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Built-in Preview
Chrome
Firefox
Safari
Sync Changes
Hide This Notification
Code changed:
Hide
4
32
168
1
28
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Tests\Unit\Services\Crm\Salesforce;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Events\Dispatcher;
use Illuminate\Support\Facades\Cache;
use Jiminny\Component\Country\CountriesMap;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Enums\CrmObject;
use Jiminny\Exceptions\InvalidArgumentException;
use Jiminny\Exceptions\NoResultsException;
use Jiminny\Integrations\PlaybookResolver;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Playbook;
use Jiminny\Models\Team;
use Jiminny\Models\TeamSettings;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\ContactRoleRepository;
use Jiminny\Repositories\Crm\FieldDataRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\Salesforce\Client;
use Jiminny\Services\Crm\Salesforce\PayloadBuilder;
use Jiminny\Services\Crm\Salesforce\QueryBuilder;
use Jiminny\Services\Crm\Salesforce\QueryHandler;
use Jiminny\Services\Crm\Salesforce\QueryIterator;
use Jiminny\Services\Crm\Salesforce\QueryResults;
use Jiminny\Services\Crm\Salesforce\Service;
use PHPUnit\Framework\MockObject\MockObject;
use Tests\TestCase;
use Tests\Unit\Traits\TestPrivateMethod;
class ServiceTest extends TestCase
{
use TestPrivateMethod;
public function testFetchAndAssociateRelatedActivity(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$payloadBuilder->method('addCustomLogicFieldsPayload')
->willReturnCallback(function ($activity, $payload) {
return $payload;
});
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods(['fetchRelatedActivity', 'getPlaybook', 'getPlaybookCategory', 'updateRecord'])
->getMock();
$serviceMock->expects($this->once())
->method('fetchRelatedActivity')
->willReturn([
'Id' => 'testId',
'Type' => null,
'OwnerId' => 'testerUser',
'Description' => 'Test description',
]);
$user = $this->createMock(User::class);
$team = $this->createMock(Team::class);
$user->method('getAttribute')->with('team')->willReturn($team);
$playbook = $this->createMock(Playbook::class);
$playbook->method('getActivityField')->willReturn(null);
$playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_EVENT);
$serviceMock->expects($this->once())
->method('getPlaybook')
->with($user)
->willReturn($playbook);
$serviceMock->expects($this->never())
->method('getPlaybookCategory');
$serviceMock->expects($this->never())
->method('updateRecord');
$fieldDataRepository = $this->createMock(FieldDataRepository::class);
$fieldDataRepository->method('getActivityFieldData')->willReturn(collect([]));
app()->instance(FieldDataRepository::class, $fieldDataRepository);
$config = $this->createMock(Configuration::class);
$profilesRelation = $this->getMockBuilder(\Illuminate\Database\Eloquent\Relations\HasMany::class)
->disableOriginalConstructor()
->onlyMethods(['get'])
->addMethods(['where'])
->getMock();
$profilesRelation->method('where')->willReturnSelf();
$profilesRelation->method('get')->willReturn(collect([]));
$config->method('profiles')->willReturn($profilesRelation);
$serviceMock->config = $config;
$serviceMock->profile = null;
$actualStartTime = \Carbon\Carbon::now();
$activity = $this->getMockBuilder(Activity::class)
->disableOriginalConstructor()
->onlyMethods(['update', 'hasProspect'])
->getMock();
$activity->method('update')->willReturn(true);
$activity->method('hasProspect')->willReturn(true);
$activity->type = Activity::TYPE_CONFERENCE;
$activity->provider = Activity::PROVIDER_TWILIO;
$activity->lead_id = 1;
$activity->user_id = 0;
$activity->id_string = 'test-activity-id';
$activity->user = $user;
$activity->actual_start_time = $actualStartTime;
$activity->uuid = 'c53d8320-f556-4cee-a2f8-5f232f454ca4';
app()->bind(PlaybookResolver::class, function () use ($user) {
$playbook = $this->createMock(Playbook::class);
$playbookResolver = $this->createMock(PlaybookResolver::class);
$playbookResolver->expects($this->once())
->method('resolvePlaybookByUser')
->with($user)
->willReturn($playbook);
return $playbookResolver;
});
$data = $serviceMock->fetchAndAssociateRelatedActivity($activity);
$this->assertInstanceOf(Activity::class, $data);
$this->assertEquals(Activity::TYPE_CONFERENCE, $data->getType());
$this->assertEquals($actualStartTime->getTimestamp(), $data->getActualStartTime()->getTimestamp());
}
public function testFetchAndAssociateRelatedActivitySkipsForTaskBasedPlaybook(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods(['fetchRelatedActivity', 'getPlaybook'])
->getMock();
$user = $this->createMock(User::class);
$playbook = $this->createMock(Playbook::class);
$playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);
$playbook->method('getId')->willReturn(123);
$serviceMock->expects($this->once())
->method('getPlaybook')
->with($user)
->willReturn($playbook);
$serviceMock->expects($this->never())
->method('fetchRelatedActivity');
$activity = $this->getMockBuilder(Activity::class)
->disableOriginalConstructor()
->onlyMethods(['hasProspect', 'getUuid'])
->getMock();
$activity->method('hasProspect')->willReturn(true);
$activity->method('getUuid')->willReturn('c53d8320-f556-4cee-a2f8-5f232f454ca4');
$activity->type = Activity::TYPE_CONFERENCE;
$activity->actual_start_time = \Carbon\Carbon::now();
$activity->user = $user;
$result = $serviceMock->fetchAndAssociateRelatedActivity($activity);
$this->assertNull($result);
}
public function testFetchAndAssociateRelatedActivityReturnsNullForNonConference(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$serviceMock = new Service(
client: $client,
payloadBuilder: $payloadBuilder,
eventDispatcher: $eventDispatcher,
countriesMap: $countriesMap,
prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)
);
$activity = $this->getMockBuilder(Activity::class)
->disableOriginalConstructor()
->getMock();
$activity->type = Activity::TYPE_SOFTPHONE;
$result = $serviceMock->fetchAndAssociateRelatedActivity($activity);
$this->assertNull($result);
}
public function testFetchAndAssociateRelatedActivityReturnsNullWhenNoStartTime(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$serviceMock = new Service(
client: $client,
payloadBuilder: $payloadBuilder,
eventDispatcher: $eventDispatcher,
countriesMap: $countriesMap,
prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)
);
$activity = $this->getMockBuilder(Activity::class)
->disableOriginalConstructor()
->getMock();
$activity->type = Activity::TYPE_CONFERENCE;
$activity->actual_start_time = null;
$activity->scheduled_start_time = null;
$result = $serviceMock->fetchAndAssociateRelatedActivity($activity);
$this->assertNull($result);
}
public function testFetchAndAssociateRelatedActivityReturnsNullWhenNoProspect(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods(['getPlaybook'])
->getMock();
$serviceMock->expects($this->never())
->method('getPlaybook');
$activity = $this->getMockBuilder(Activity::class)
->disableOriginalConstructor()
->onlyMethods(['hasProspect', 'getUuid'])
->getMock();
$activity->method('hasProspect')->willReturn(false);
$activity->method('getUuid')->willReturn('c53d8320-f556-4cee-a2f8-5f232f454ca4');
$activity->type = Activity::TYPE_CONFERENCE;
$activity->actual_start_time = \Carbon\Carbon::now();
$result = $serviceMock->fetchAndAssociateRelatedActivity($activity);
$this->assertNull($result);
}
public function testMatchExactlyByEmail(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods([])
->getMock();
$profile = new Profile();
$profile->setAttribute('id', bin2hex(random_bytes(8)));
$serviceMock->profile = $profile;
$team = $this->createMock(Team::class);
$serviceMock->team = $team;
$data = $serviceMock->matchExactlyByEmail(bin2hex(random_bytes(8)) . '[EMAIL]');
$this->assertEquals(null, $data);
}
public function testMatchDomainFromEmail(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$queryIterator = $this->createMock(QueryIterator::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$config = $this->createMock(Configuration::class);
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('search')
->willReturn($queryIterator);
return $handler;
});
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods(['convertCrmData'])
->getMock();
$profile = new Profile();
$profile->account_fields = 'Field1, Field2, Field3';
$serviceMock->profile = $profile;
$serviceMock->expects($this->once())
->method('convertCrmData')
->willReturn(['test']);
$this->app->bind(QueryBuilder::class, function () {
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->expects($this->once())
->method('buildMatchByDomainQuery')
->with('[EMAIL]')
->willReturn('FIND {[EMAIL]} IN ALL FIELDS RETURNING Account(Id)');
return $queryBuilder;
});
$team = $this->createMock(Team::class);
$serviceMock->team = $team;
$serviceMock->config = $config;
$data = $serviceMock->matchByDomain('[EMAIL]');
$this->assertEquals(['test'], $data);
}
public function testBuildTaskSearchFields(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$service = new Service(
client: $client,
payloadBuilder: $payloadBuilder,
eventDispatcher: $eventDispatcher,
countriesMap: $countriesMap,
prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)
);
$fields = $service->buildTaskSearchFields();
$expectedFields = ['Id', 'WhoId', 'WhatId', 'AccountId'];
$this->assertEquals($expectedFields, $fields);
}
public function testMapCrmObjects(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$service = new Service(
client: $client,
payloadBuilder: $payloadBuilder,
eventDispatcher: $eventDispatcher,
countriesMap: $countriesMap,
prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class),
);
$sampleTask = [
'WhoId' => '003sampleWhoId',
'AccountId' => 'sampleAccountId',
'WhatId' => 'sampleWhatId',
];
$activityData = $service->mapCrmObjects($sampleTask);
$expectedActivityData = [
'contact' => '003sampleWhoId',
'account' => 'sampleAccountId',
'opportunity' => 'sampleWhatId',
];
$this->assertEquals($expectedActivityData, $activityData);
}
public function testGetInstalledAppVersion(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$queryIterator = $this->createMock(QueryIterator::class);
$queryIterator->expects($this->any())
->method('current')->willReturn([
'SubscriberPackageVersion' => [
'MajorVersion' => '1',
'MinorVersion' => '0',
'PatchVersion' => '1',
'BuildNumber' => '0',
],
]);
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('metadata')
->willReturn($queryIterator);
return $handler;
});
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods(array_diff(get_class_methods(Service::class), ['getInstalledAppVersion']))
->getMock();
$version = $serviceMock->getInstalledAppVersion();
$this->assertEquals('1010', $version);
}
public function testSyncProfiles(): void
{
$userToSearch = null;
$team = $this->createMock(Team::class);
$config = $this->createMockedConfiguration();
$config->expects($this->once())
->method('getId')
->willReturn(1);
$salesforceUser = [
'Email' => '[EMAIL]',
'UserPreferencesLightningExperiencePreferred' => true,
'CallCenterId' => '123',
'Id' => '456',
'ProfileId' => '789',
];
app()->bind(QueryBuilder::class, function () use ($userToSearch) {
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->expects($this->once())
->method('buildGetUsersQuery')
->with($userToSearch)
->willReturn('SELECT * FROM Users');
return $queryBuilder;
});
$queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('query')
->with('SELECT * FROM Users')
->willReturn($queryIterator);
return $handler;
});
$user = $this->createMock(User::class);
$user->expects($this->once())
->method('getId')
->willReturn(123);
$this->mockTeamRepository($team, $salesforceUser, $user);
$profileRepository = $this->createMock(ProfileRepository::class);
$profileRepository->expects($this->once())
->method('updateOrCreateProfile')
->with(
$user,
[
'crm_configuration_id' => 1,
'crm_provider_id' => '456',
],
[
'user_id' => 123,
'edition' => Profile::EDITION_LIGHTNING,
'has_external_cti' => true,
'crm_profile_id' => '789',
]
)
->willReturn(new Profile());
$this->app->instance(ProfileRepository::class, $profileRepository);
$serviceMock = $this->getServiceMock();
$serviceMock->team = $team;
$serviceMock->config = $config;
$result = $serviceMock->syncProfiles($userToSearch);
$this->assertNull($result);
}
public function testSyncProfilesEmailIsNull(): void
{
$userToSearch = $this->createMock(User::class);
$salesforceUser = [
'Email' => null,
];
app()->bind(QueryBuilder::class, function () {
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->expects($this->once())
->method('buildGetUsersQuery')
->with(null)
->willReturn('SELECT * FROM Users');
return $queryBuilder;
});
$queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->exactly(2))
->method('query')
->willReturn($queryIterator);
return $handler;
});
$team = $this->createMock(Team::class);
$user = $this->createMock(User::class);
$this->mockTeamRepository($team, $salesforceUser, $user, false);
$config = $this->createMock(Configuration::class);
$serviceMock = $this->getServiceMock();
$serviceMock->team = $team;
$serviceMock->config = $config;
$profile = $serviceMock->syncProfiles(null);
$this->assertNull($profile);
}
public function testSyncProfilesUserToSearchMatchesCurrentUser(): void
{
$userToSearch = $this->createMock(User::class);
$userToSearch->expects($this->once())
->method('getId')
->willReturn(123);
$team = $this->createMock(Team::class);
$config = $this->createMock(Configuration::class);
$config->expects($this->once())
->method('getId')
->willReturn(1);
$salesforceUser = [
'Email' => '[EMAIL]',
'UserPreferencesLightningExperiencePreferred' => true,
'CallCenterId' => '123',
'Id' => '456',
'ProfileId' => '789',
];
app()->bind(QueryBuilder::class, function () use ($userToSearch) {
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->expects($this->once())
->method('buildGetUsersQuery')
->with($userToSearch)
->willReturn('SELECT * FROM Users');
return $queryBuilder;
});
$queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('query')
->with('SELECT * FROM Users')
->willReturn($queryIterator);
return $handler;
});
$user = $this->createMock(User::class);
$user->expects($this->exactly(2))
->method('getId')
->willReturn(123);
$this->mockTeamRepository($team, $salesforceUser, $user);
$profileRepository = $this->createMock(ProfileRepository::class);
$profileRepository->expects($this->once())
->method('updateOrCreateProfile')
->with(
$user,
[
'crm_configuration_id' => 1,
'crm_provider_id' => '456',
],
[
'user_id' => 123,
'edition' => Profile::EDITION_LIGHTNING,
'has_external_cti' => true,
'crm_profile_id' => '789',
]
)
->willReturn(new Profile());
$this->app->instance(ProfileRepository::class, $profileRepository);
$serviceMock = $this->getServiceMock();
$serviceMock->team = $team;
$serviceMock->config = $config;
$profile = $serviceMock->syncProfiles($userToSearch);
$this->assertInstanceOf(Profile::class, $profile);
}
public function testSyncProfilesWithCustomValidation(): void
{
$userToSearch = null;
$team = $this->createMock(Team::class);
$config = $this->createMockedConfiguration();
$config->expects($this->atLeastOnce()) // Changed from once() to atLeastOnce()
->method('getId')
->willReturn(1);
$salesforceUser = [
'Email' => '[EMAIL]',
'UserPreferencesLightningExperiencePreferred' => true,
'CallCenterId' => '123',
'Id' => '456',
'ProfileId' => '789',
'CustomField' => 'CustomValue',
];
$customRules = [
['field' => 'CustomField', 'value' => 'CustomValue'],
];
$this->mockQueryBuilderAndHandler($userToSearch, [$salesforceUser]);
$user = $this->createMock(User::class);
$user->expects($this->once())
->method('getId')
->willReturn(123);
$this->mockTeamRepository($team, $salesforceUser, $user, true, $customRules);
$this->mockProfileRepository($user);
$serviceMock = $this->getServiceMock();
$serviceMock->team = $team;
$serviceMock->config = $config;
$profile = $serviceMock->syncProfiles($userToSearch);
$this->assertNull($profile);
}
public function testSyncProfilesWithCustomValidationFailing(): void
{
$userToSearch = null;
$team = $this->createMock(Team::class);
$config = $this->createMockedConfiguration();
$salesforceUser = [
'Email' => '[EMAIL]',
'UserPreferencesLightningExperiencePreferred' => true,
'CallCenterId' => '123',
'Id' => '456',
'ProfileId' => '789',
'CustomField' => 'WrongValue',
];
$customRules = [
['field' => 'CustomField', 'value' => 'CustomValue'],
];
$this->mockQueryBuilderAndHandler($userToSearch, [$salesforceUser]);
$teamRepository = $this->getMockForAbstractClass(TeamRepository::class, [], '', false, true, true, ['findActiveTeamMemberByEmail', 'getTeamSetting']);
$teamSettings = $this->createMock(TeamSettings::class);
$teamSettings->method('getValueType')
->willReturn('array');
$teamSettings->method('getValue')
->willReturn(json_encode($customRules));
$teamRepository->expects($this->once())
->method('getTeamSetting')
->with($team, 'custom_profile_validation')
->willReturn($teamSettings);
app()->bind(TeamRepository::class, function () use ($teamRepository) {
return $teamRepository;
});
$profileRepository = $this->createMock(ProfileRepository::class);
$profileRepository->expects($this->never())
->method('updateOrCreateProfile');
$this->app->instance(ProfileRepository::class, $profileRepository);
$serviceMock = $this->getServiceMock();
$serviceMock->team = $team;
$serviceMock->config = $config;
$result = $serviceMock->syncProfiles($userToSearch);
$this->assertNull($result);
}
public function testGetContactRolesFromCrm(): void
{
$contactRoles = [
[
'Id' => '1',
'ContactId' => 'Contact1',
'OpportunityId' => 'Opportunity1',
'Opportunity' => ['OwnerId' => 'Owner1'],
'IsPrimary' => true,
'Role' => 'Decision Maker',
],
];
$expectedResponse = [
[
'id' => '1',
'contactId' => 'Contact1',
'opportunityId' => 'Opportunity1',
'ownerId' => 'Owner1',
'isPrimary' => true,
'role' => 'Decision Maker',
],
];
$this->bindQueryIterator($contactRoles);
$serviceMock = $this->getServiceMock();
$data = $serviceMock->getContactRolesFromCrm(now()->subDay());
$this->assertEquals($expectedResponse, $data);
}
public function testGetContactRolesFromCrmNoResult(): void
{
$this->bindQueryIterator([]);
$serviceMock = $this->getServiceMock();
$data = $serviceMock->getContactRolesFromCrm(now()->subDay());
$this->assertEquals([], $data);
}
public function testSyncContactRoles(): void
{
$contactRoles = [
[
'id' => '1',
'contactId' => 'Contact1',
'opportunityId' => 'Opportunity1',
'ownerId' => 'Owner1',
'isPrimary' => true,
'role' => 'Decision Maker',
],
];
app()->bind(ContactRoleRepository::class, function () {
$contactRoleRepository = $this->createMock(ContactRoleRepository::class);
$contactRoleRepository->expects($this->once())
->method('saveContactRoles');
return $contactRoleRepository;
});
$serviceMock = $this->getServiceMock([
'getContactRolesFromCrm',
'syncRemotelyDeletedContactRoles',
'syncContact',
'syncOpportunity',
]);
$config = $this->createMock(Configuration::class);
$hasMany = $this->createMock(HasManyExtended::class);
$hasMany->expects($this->exactly(2))
->method('where')
->willReturn($hasMany);
$hasMany->expects($this->exactly(2))
->method('first')
->willReturn(
$this->createMock(Contact::class),
$this->createMock(Opportunity::class)
);
$config->expects($this->once())
->method('contacts')
->willReturn($hasMany);
$config->expects($this->once())
->method('opportunities')
->willReturn($hasMany);
$serviceMock->config = $config;
$serviceMock->expects($this->once())
->method('getContactRolesFromCrm')
->willReturn($contactRoles);
$serviceMock->expects($this->once())
->method('syncRemotelyDeletedContactRoles');
$data = $serviceMock->syncContactRoles(now()->subDay());
$this->assertEquals(1, $data);
}
public function testSyncRemotelyDeletedContactRoles(): void
{
$contactRoles = [
[
'id' => '1',
'crm_provider_id' => '1',
],
];
app()->bind(QueryHandler::class, function () use ($contactRoles) {
$queryResults = new QueryResults($contactRoles, 1, true, null);
$handler = $this->createMock(QueryHandler::class);
$handler->method('queryDeleted')
->willReturn($queryResults);
return $handler;
});
app()->bind(ContactRoleRepository::class, function () {
$contactRoleRepository = $this->createMock(ContactRoleRepository::class);
$contactRoleRepository->expects($this->once())
->method('deleteContactRoles');
return $contactRoleRepository;
});
$serviceMock = $this->getServiceMock();
$serviceMock->team = $this->createMock(Team::class);
$data = $this->invokePrivateMethod('syncRemotelyDeletedContactRoles', $serviceMock, []);
$this->assertTrue($data);
}
private function bindQueryIterator(array $queryResult): void
{
/** @var Client $client */
$client = $this->createMock(Client::class);
$queryIterator = new QueryIterator(
$client,
new QueryResults($queryResult, 1, true, null)
);
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('query')
->willReturn($queryIterator);
return $handler;
});
}
public static function getOpportunitySortOrderDataProvider(): array
{
return [
'all open recently updated' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED, ['LastModifiedDate DESC', true]],
'all open recently created' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED, ['CreatedDate DESC', true]],
'all open oldest created' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED, ['CreatedDate ASC', true]],
'all recently updated' => [Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED, ['LastModifiedDate DESC', false]],
'default' => ['unknown', ['LastModifiedDate DESC', true]],
];
}
private function createMockedConfiguration(): Configuration
{
$config = $this->createMock(Configuration::class);
$profilesRelation = $this->getMockBuilder(\Illuminate\Database\Eloquent\Relations\HasMany::class)
->disableOriginalConstructor()
->onlyMethods(['get'])
->addMethods(['where', 'first'])
->getMock();
$profilesRelation->method('where')->willReturnSelf();
$profilesRelation->method('get')->willReturn(collect([]));
$profilesRelation->method('first')->willReturn(null);
$config->method('profiles')->willReturn($profilesRelation);
return $config;
}
private function getServiceMock(array $onlyMethods = []): MockObject&Service
{
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$this->createMock(Client::class),
$this->createMock(PayloadBuilder::class),
$this->createMock(Dispatcher::class),
$this->createMock(CountriesMap::class),
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods($onlyMethods)
->getMock();
$serviceMock->profile = $this->createMock(Profile::class);
return $serviceMock;
}
private function mockQueryBuilderAndHandler($userToSearch, $salesforceUsers): void
{
app()->bind(QueryBuilder::class, function () use ($userToSearch) {
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->expects($this->once())
->method('buildGetUsersQuery')
->with($userToSearch)
->willReturn('SELECT * FROM Users');
return $queryBuilder;
});
$queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults($salesforceUsers, count($salesforceUsers), true, null));
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('query')
->willReturn($queryIterator);
return $handler;
});
}
private function mockTeamRepository(
Team $team,
array $salesforceUser,
?User $user = null,
bool $userSearch = true,
array $customRules = []
): void {
$teamRepository = $this->getMockForAbstractClass(TeamRepository::class, [], '', false, true, true, ['findActiveTeamMemberByEmail', 'getTeamSetting']);
if ($userSearch) {
$teamRepository->expects($this->once())
->method('findActiveTeamMemberByEmail')
->with($team, $salesforceUser['Email'])
->willReturn($user);
}
$teamSettings = $this->createMock(TeamSettings::class);
$teamSettings->method('getValueType')
->willReturn('array');
$teamSettings->method('getValue')
->willReturn(json_encode($customRules));
$teamRepository->expects($this->once())
->method('getTeamSetting')
->with($team, 'custom_profile_validation')
->willReturn($teamSettings);
app()->bind(TeamRepository::class, function () use ($teamRepository) {
return $teamRepository;
});
}
private function mockProfileRepository(User $user): void
{
$profileRepository = $this->createMock(ProfileRepository::class);
$profileRepository->expects($this->once())
->method('updateOrCreateProfile')
->with(
$user,
[
'crm_configuration_id' => 1,
'crm_provider_id' => '456',
],
[
'user_id' => 123,
'edition' => Profile::EDITION_LIGHTNING,
'has_external_cti' => true,
'crm_profile_id' => '789',
]
)
->willReturn(new Profile());
$this->app->instance(ProfileRepository::class, $profileRepository);
}
public function testBuildEnhancedNoteDecodesHtmlEntities(): void
{
$service = $this->getServiceMock(['createRecord']);
$profile = new Profile();
$profile->setAttribute('crm_provider_id', 'owner-123');
$service->profile = $profile;
$service->expects($this->exactly(2))
->method('createRecord')
->willReturnOnConsecutiveCalls('note-id-123', 'link-id-456');
$bodyWithEntities = 'Welch's current challenges and Facebook's Club';
$result = $this->invokePrivateMethod('buildEnhancedNote', $service, [
'Test Title',
$bodyWithEntities,
'object-id-789',
]);
$this->assertEquals('note-id-123', $result);
}
public function testBuildEnhancedNoteSanitizesWithoutQuotes(): void
{
$service = $this->getServiceMock(['createRecord']);
$profile = new Profile();
$profile->setAttribute('crm_provider_id', 'owner-456');
$service->profile = $profile;
$service->expects($this->exactly(2))
->method('createRecord')
->willReturnCallback(function ($type, $data) {
if ($type === 'ContentNote') {
$decoded = base64_decode($data['Content']);
$this->assertStringContainsString("Welch's", $decoded);
$this->assertStringNotContainsString(''', $decoded);
$this->assertStringNotContainsString('&#039;', $decoded);
$this->assertStringContainsString('<script>', $decoded);
return 'note-id-456';
}
return 'link-id-789';
});
$bodyWithMixedContent = "Welch's and <script>alert('xss')</script>";
$result = $this->invokePrivateMethod('buildEnhancedNote', $service, [
'Test Title',
$bodyWithMixedContent,
'object-id-123',
]);
$this->assertEquals('note-id-456', $result);
}
public function testBuildEnhancedNoteConvertsLineBreaks(): void
{
$service = $this->getServiceMock(['createRecord']);
$profile = new Profile();
$profile->setAttribute('crm_provider_id', 'owner-789');
$service->profile = $profile;
$service->expects($this->exactly(2))
->method('createRecord')
->willReturnCallback(function ($type, $data) {
if ($type === 'ContentNote') {
$decoded = base64_decode($data['Content']);
$this->assertStringContainsString('<br>', $decoded);
$this->assertStringNotContainsString('<br />', $decoded);
return 'note-id-789';
}
return 'link-id-012';
});
$bodyWithLineBreaks = "Line 1\nLine 2\nLine 3";
$result = $this->invokePrivateMethod('buildEnhancedNote', $service, [
'Test Title',
$bodyWithLineBreaks,
'object-id-456',
]);
$this->assertEquals('note-id-789', $result);
}
public function testBuildEnhancedNoteHandlesComplexScenario(): void
{
$service = $this->getServiceMock(['createRecord']);
$profile = new Profile();
$profile->setAttribute('crm_provider_id', 'owner-complex');
$service->profile = $profile;
$service->expects($this->exactly(2))
->method('createRecord')
->willReturnCallback(function ($type, $data) {
if ($type === 'ContentNote') {
$decoded = base64_decode($data['Content']);
$this->assertStringContainsString("Welch's", $decoded);
$this->assertStringContainsString("Facebook's Club", $decoded);
$this->assertStringContainsString("Arctics'", $decoded);
$this->assertStringNotContainsString(''', $decoded);
$this->assertStringNotContainsString('&#039;', $decoded);
$this->assertStringContainsString('<br>', $decoded);
$this->assertStringContainsString('<', $decoded);
$this->assertStringContainsString('>', $decoded);
return 'note-complex';
}
return 'link-complex';
});
$complexBody = "Summary:\n---------\nThe call focused on understanding Welch's current challenges and exploring how Arctics' Revenue Growth Management solutions could support their strategic goals.\n\n• John SMith discussed his role as a category advisor for Google and Facebook's Club, emphasizing the importance of market research and advising on product assortment.\n• Madona introduced Arctics' Virtual Shoppers AI, which simulates consumer <behavior> to optimize pricing and promotional strategies.";
$result = $this->invokePrivateMethod('buildEnhancedNote', $service, [
'Jiminny Transcription Summary',
$complexBody,
'task-id-001',
]);
$this->assertEquals('note-complex', $result);
}
public function testSyncRemotelyDeletedObjectsWithErrorHandlingSuccess(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$prospectPhotoPathService,
])
->onlyMethods(['syncRemotelyDeletedObjects'])
->getMock();
// Mock team
$team = $this->createMock(Team::class);
$team->method('getUuid')->willReturn('team-uuid-123');
$serviceMock->team = $team;
// Expect syncRemotelyDeletedObjects to be called once and succeed
$serviceMock->expects($this->once())
->method('syncRemotelyDeletedObjects')
->with(\Jiminny\Enums\CrmObject::ACCOUNT);
// Call the protected method using reflection
$reflection = new \ReflectionClass($serviceMock);
$method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');
$method->setAccessible(true);
// Should not throw any exceptions
$method->invoke($serviceMock, \Jiminny\Enums\CrmObject::ACCOUNT);
$this->assertTrue(true); // Test completed successfully
}
public function testSyncRemotelyDeletedObjectsWithErrorHandlingFailure(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$prospectPhotoPathService,
])
->onlyMethods(['syncRemotelyDeletedObjects'])
->getMock();
// Mock team
$team = $this->createMock(Team::class);
$team->method('getUuid')->willReturn('team-uuid-456');
$serviceMock->team = $team;
// Mock logger to verify warning is logged
$logger = $this->createMock(\Psr\Log\LoggerInterface::class);
// Use reflection to set the protected logger property
$reflection = new \ReflectionClass($serviceMock);
$loggerProperty = $reflection->getProperty('logger');
$loggerProperty->setAccessible(true);
$loggerProperty->setValue($serviceMock, $logger);
$exception = new \Exception('Sync failed due to API error');
// Expect syncRemotelyDeletedObjects to throw an exception
$serviceMock->expects($this->once())
->method('syncRemotelyDeletedObjects')
->with(\Jiminny\Enums\CrmObject::CONTACT)
->willThrowException($exception);
// Expect warning to be logged with correct message and parameters
$logger->expects($this->once())
->method('warning')
->with(
'[Salesforce] Remotely deleted objects sync failed',
[
'objectType' => 'contact',
'teamId' => 'team-uuid-456',
'reason' => 'Sync failed due to API error',
]
);
// Call the protected method using reflection
$reflection = new \ReflectionClass($serviceMock);
$method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');
$method->setAccessible(true);
// Should not re-throw the exception, just log it
$method->invoke($serviceMock, \Jiminny\Enums\CrmObject::CONTACT);
$this->assertTrue(true); // Test completed successfully
}
public function testSyncRemotelyDeletedObjectsWithErrorHandlingWithLogParams(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$prospectPhotoPathService,
])
->onlyMethods(['syncRemotelyDeletedObjects'])
->getMock();
// Mock team
$team = $this->createMock(Team::class);
$team->method('getUuid')->willReturn('team-uuid-789');
$serviceMock->team = $team;
// Mock logger to verify warning is logged
$logger = $this->createMock(\Psr\Log\LoggerInterface::class);
// Use reflection to set the protected logger property
$loggerReflection = new \ReflectionClass($serviceMock);
$loggerProperty = $loggerReflection->getProperty('logger');
$loggerProperty->setAccessible(true);
$loggerProperty->setValue($serviceMock, $logger);
$exception = new \Exception('Network timeout');
// Expect syncRemotelyDeletedObjects to throw an exception
$serviceMock->expects($this->once())
->method('syncRemotelyDeletedObjects')
->with(\Jiminny\Enums\CrmObject::OPPORTUNITY)
->willThrowException($exception);
// Additional log parameters
$logParams = [
'syncType' => 'full',
'batchSize' => 100,
];
// Expect warning to be logged with merged parameters
$logger->expects($this->once())
->method('warning')
->with(
'[Salesforce] Remotely deleted objects sync failed',
[
'objectType' => 'opportunity',
'teamId' => 'team-uuid-789',
'reason' => 'Network timeout',
'syncType' => 'full',
'batchSize' => 100,
]
);
// Call the protected method using reflection
$reflection = new \ReflectionClass($serviceMock);
$method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');
...
|
[{"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-20963-fix-import-on-deleted-entity, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.09607713,"height":0.025538707},"on_screen":true,"help_text":"Git Branch: JY-20963-fix-import-on-deleted-entity","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.85638297,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"ServiceTest","depth":6,"bounds":{"left":0.87167555,"top":0.019952115,"width":0.043882977,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Rerun 'PHPUnit: ServiceTest'","depth":6,"bounds":{"left":0.9155585,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'ServiceTest'","depth":6,"bounds":{"left":0.9268617,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Stop 'ServiceTest'","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":"More Actions","depth":6,"bounds":{"left":0.9494681,"top":0.019952115,"width":0.011303191,"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":"Built-in Preview","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":"Chrome","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":"Firefox","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":"Safari","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":"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":"4","depth":4,"bounds":{"left":0.35472074,"top":0.10055866,"width":0.007978723,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"32","depth":4,"bounds":{"left":0.36469415,"top":0.10055866,"width":0.010305851,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"168","depth":4,"bounds":{"left":0.37699467,"top":0.10055866,"width":0.011968086,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"bounds":{"left":0.39095744,"top":0.10055866,"width":0.00731383,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"28","depth":4,"bounds":{"left":0.40026596,"top":0.10055866,"width":0.009973404,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.4119016,"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.4192154,"top":0.09896249,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Services\\Crm\\Salesforce;\n\nuse Illuminate\\Database\\Eloquent\\Relations\\HasMany;\nuse Illuminate\\Events\\Dispatcher;\nuse Illuminate\\Support\\Facades\\Cache;\nuse Jiminny\\Component\\Country\\CountriesMap;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Enums\\CrmObject;\nuse Jiminny\\Exceptions\\InvalidArgumentException;\nuse Jiminny\\Exceptions\\NoResultsException;\nuse Jiminny\\Integrations\\PlaybookResolver;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Crm\\Configuration;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\TeamSettings;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\ContactRoleRepository;\nuse Jiminny\\Repositories\\Crm\\FieldDataRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\Salesforce\\Client;\nuse Jiminny\\Services\\Crm\\Salesforce\\PayloadBuilder;\nuse Jiminny\\Services\\Crm\\Salesforce\\QueryBuilder;\nuse Jiminny\\Services\\Crm\\Salesforce\\QueryHandler;\nuse Jiminny\\Services\\Crm\\Salesforce\\QueryIterator;\nuse Jiminny\\Services\\Crm\\Salesforce\\QueryResults;\nuse Jiminny\\Services\\Crm\\Salesforce\\Service;\nuse PHPUnit\\Framework\\MockObject\\MockObject;\nuse Tests\\TestCase;\nuse Tests\\Unit\\Traits\\TestPrivateMethod;\n\nclass ServiceTest extends TestCase\n{\n use TestPrivateMethod;\n\n public function testFetchAndAssociateRelatedActivity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $payloadBuilder->method('addCustomLogicFieldsPayload')\n ->willReturnCallback(function ($activity, $payload) {\n return $payload;\n });\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods(['fetchRelatedActivity', 'getPlaybook', 'getPlaybookCategory', 'updateRecord'])\n ->getMock();\n\n $serviceMock->expects($this->once())\n ->method('fetchRelatedActivity')\n ->willReturn([\n 'Id' => 'testId',\n 'Type' => null,\n 'OwnerId' => 'testerUser',\n 'Description' => 'Test description',\n ]);\n\n $user = $this->createMock(User::class);\n $team = $this->createMock(Team::class);\n $user->method('getAttribute')->with('team')->willReturn($team);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityField')->willReturn(null);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_EVENT);\n\n $serviceMock->expects($this->once())\n ->method('getPlaybook')\n ->with($user)\n ->willReturn($playbook);\n\n $serviceMock->expects($this->never())\n ->method('getPlaybookCategory');\n\n $serviceMock->expects($this->never())\n ->method('updateRecord');\n\n $fieldDataRepository = $this->createMock(FieldDataRepository::class);\n $fieldDataRepository->method('getActivityFieldData')->willReturn(collect([]));\n app()->instance(FieldDataRepository::class, $fieldDataRepository);\n\n $config = $this->createMock(Configuration::class);\n $profilesRelation = $this->getMockBuilder(\\Illuminate\\Database\\Eloquent\\Relations\\HasMany::class)\n ->disableOriginalConstructor()\n ->onlyMethods(['get'])\n ->addMethods(['where'])\n ->getMock();\n $profilesRelation->method('where')->willReturnSelf();\n $profilesRelation->method('get')->willReturn(collect([]));\n $config->method('profiles')->willReturn($profilesRelation);\n\n $serviceMock->config = $config;\n $serviceMock->profile = null;\n\n $actualStartTime = \\Carbon\\Carbon::now();\n\n $activity = $this->getMockBuilder(Activity::class)\n ->disableOriginalConstructor()\n ->onlyMethods(['update', 'hasProspect'])\n ->getMock();\n\n $activity->method('update')->willReturn(true);\n $activity->method('hasProspect')->willReturn(true);\n\n $activity->type = Activity::TYPE_CONFERENCE;\n $activity->provider = Activity::PROVIDER_TWILIO;\n $activity->lead_id = 1;\n $activity->user_id = 0;\n $activity->id_string = 'test-activity-id';\n $activity->user = $user;\n\n $activity->actual_start_time = $actualStartTime;\n $activity->uuid = 'c53d8320-f556-4cee-a2f8-5f232f454ca4';\n\n app()->bind(PlaybookResolver::class, function () use ($user) {\n $playbook = $this->createMock(Playbook::class);\n $playbookResolver = $this->createMock(PlaybookResolver::class);\n $playbookResolver->expects($this->once())\n ->method('resolvePlaybookByUser')\n ->with($user)\n ->willReturn($playbook);\n\n return $playbookResolver;\n });\n\n $data = $serviceMock->fetchAndAssociateRelatedActivity($activity);\n\n $this->assertInstanceOf(Activity::class, $data);\n $this->assertEquals(Activity::TYPE_CONFERENCE, $data->getType());\n $this->assertEquals($actualStartTime->getTimestamp(), $data->getActualStartTime()->getTimestamp());\n }\n\n public function testFetchAndAssociateRelatedActivitySkipsForTaskBasedPlaybook(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods(['fetchRelatedActivity', 'getPlaybook'])\n ->getMock();\n\n $user = $this->createMock(User::class);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n $playbook->method('getId')->willReturn(123);\n\n $serviceMock->expects($this->once())\n ->method('getPlaybook')\n ->with($user)\n ->willReturn($playbook);\n\n $serviceMock->expects($this->never())\n ->method('fetchRelatedActivity');\n\n $activity = $this->getMockBuilder(Activity::class)\n ->disableOriginalConstructor()\n ->onlyMethods(['hasProspect', 'getUuid'])\n ->getMock();\n\n $activity->method('hasProspect')->willReturn(true);\n $activity->method('getUuid')->willReturn('c53d8320-f556-4cee-a2f8-5f232f454ca4');\n $activity->type = Activity::TYPE_CONFERENCE;\n $activity->actual_start_time = \\Carbon\\Carbon::now();\n $activity->user = $user;\n\n $result = $serviceMock->fetchAndAssociateRelatedActivity($activity);\n\n $this->assertNull($result);\n }\n\n public function testFetchAndAssociateRelatedActivityReturnsNullForNonConference(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $serviceMock = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)\n );\n\n $activity = $this->getMockBuilder(Activity::class)\n ->disableOriginalConstructor()\n ->getMock();\n\n $activity->type = Activity::TYPE_SOFTPHONE;\n\n $result = $serviceMock->fetchAndAssociateRelatedActivity($activity);\n\n $this->assertNull($result);\n }\n\n public function testFetchAndAssociateRelatedActivityReturnsNullWhenNoStartTime(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $serviceMock = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)\n );\n\n\n $activity = $this->getMockBuilder(Activity::class)\n ->disableOriginalConstructor()\n ->getMock();\n\n $activity->type = Activity::TYPE_CONFERENCE;\n $activity->actual_start_time = null;\n $activity->scheduled_start_time = null;\n\n $result = $serviceMock->fetchAndAssociateRelatedActivity($activity);\n\n $this->assertNull($result);\n }\n\n public function testFetchAndAssociateRelatedActivityReturnsNullWhenNoProspect(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods(['getPlaybook'])\n ->getMock();\n\n $serviceMock->expects($this->never())\n ->method('getPlaybook');\n\n $activity = $this->getMockBuilder(Activity::class)\n ->disableOriginalConstructor()\n ->onlyMethods(['hasProspect', 'getUuid'])\n ->getMock();\n\n $activity->method('hasProspect')->willReturn(false);\n $activity->method('getUuid')->willReturn('c53d8320-f556-4cee-a2f8-5f232f454ca4');\n $activity->type = Activity::TYPE_CONFERENCE;\n $activity->actual_start_time = \\Carbon\\Carbon::now();\n\n $result = $serviceMock->fetchAndAssociateRelatedActivity($activity);\n\n $this->assertNull($result);\n }\n\n public function testMatchExactlyByEmail(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods([])\n ->getMock();\n\n $profile = new Profile();\n $profile->setAttribute('id', bin2hex(random_bytes(8)));\n $serviceMock->profile = $profile;\n\n $team = $this->createMock(Team::class);\n $serviceMock->team = $team;\n\n $data = $serviceMock->matchExactlyByEmail(bin2hex(random_bytes(8)) . 'test_email@testserver.com');\n\n $this->assertEquals(null, $data);\n }\n\n public function testMatchDomainFromEmail(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $queryIterator = $this->createMock(QueryIterator::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $config = $this->createMock(Configuration::class);\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('search')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods(['convertCrmData'])\n ->getMock();\n\n $profile = new Profile();\n $profile->account_fields = 'Field1, Field2, Field3';\n $serviceMock->profile = $profile;\n\n $serviceMock->expects($this->once())\n ->method('convertCrmData')\n ->willReturn(['test']);\n\n $this->app->bind(QueryBuilder::class, function () {\n $queryBuilder = $this->createMock(QueryBuilder::class);\n $queryBuilder->expects($this->once())\n ->method('buildMatchByDomainQuery')\n ->with('test_email@testserver.com')\n ->willReturn('FIND {test_email@testserver.com} IN ALL FIELDS RETURNING Account(Id)');\n\n return $queryBuilder;\n });\n\n $team = $this->createMock(Team::class);\n\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n\n $data = $serviceMock->matchByDomain('test_email@testserver.com');\n\n $this->assertEquals(['test'], $data);\n }\n\n public function testBuildTaskSearchFields(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)\n );\n\n $fields = $service->buildTaskSearchFields();\n\n $expectedFields = ['Id', 'WhoId', 'WhatId', 'AccountId'];\n\n $this->assertEquals($expectedFields, $fields);\n }\n\n public function testMapCrmObjects(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class),\n );\n\n $sampleTask = [\n 'WhoId' => '003sampleWhoId',\n 'AccountId' => 'sampleAccountId',\n 'WhatId' => 'sampleWhatId',\n ];\n\n $activityData = $service->mapCrmObjects($sampleTask);\n\n $expectedActivityData = [\n 'contact' => '003sampleWhoId',\n 'account' => 'sampleAccountId',\n 'opportunity' => 'sampleWhatId',\n ];\n\n $this->assertEquals($expectedActivityData, $activityData);\n }\n\n public function testGetInstalledAppVersion(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n $queryIterator = $this->createMock(QueryIterator::class);\n $queryIterator->expects($this->any())\n ->method('current')->willReturn([\n 'SubscriberPackageVersion' => [\n 'MajorVersion' => '1',\n 'MinorVersion' => '0',\n 'PatchVersion' => '1',\n 'BuildNumber' => '0',\n ],\n ]);\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('metadata')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods(array_diff(get_class_methods(Service::class), ['getInstalledAppVersion']))\n ->getMock();\n\n $version = $serviceMock->getInstalledAppVersion();\n\n $this->assertEquals('1010', $version);\n }\n\n public function testSyncProfiles(): void\n {\n $userToSearch = null;\n\n $team = $this->createMock(Team::class);\n $config = $this->createMockedConfiguration();\n $config->expects($this->once())\n ->method('getId')\n ->willReturn(1);\n\n $salesforceUser = [\n 'Email' => 'test@example.com',\n 'UserPreferencesLightningExperiencePreferred' => true,\n 'CallCenterId' => '123',\n 'Id' => '456',\n 'ProfileId' => '789',\n ];\n\n app()->bind(QueryBuilder::class, function () use ($userToSearch) {\n $queryBuilder = $this->createMock(QueryBuilder::class);\n $queryBuilder->expects($this->once())\n ->method('buildGetUsersQuery')\n ->with($userToSearch)\n ->willReturn('SELECT * FROM Users');\n\n return $queryBuilder;\n });\n\n $queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('query')\n ->with('SELECT * FROM Users')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n\n $user = $this->createMock(User::class);\n $user->expects($this->once())\n ->method('getId')\n ->willReturn(123);\n\n $this->mockTeamRepository($team, $salesforceUser, $user);\n\n $profileRepository = $this->createMock(ProfileRepository::class);\n $profileRepository->expects($this->once())\n ->method('updateOrCreateProfile')\n ->with(\n $user,\n [\n 'crm_configuration_id' => 1,\n 'crm_provider_id' => '456',\n ],\n [\n 'user_id' => 123,\n 'edition' => Profile::EDITION_LIGHTNING,\n 'has_external_cti' => true,\n 'crm_profile_id' => '789',\n ]\n )\n ->willReturn(new Profile());\n\n $this->app->instance(ProfileRepository::class, $profileRepository);\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n $result = $serviceMock->syncProfiles($userToSearch);\n\n $this->assertNull($result);\n }\n\n public function testSyncProfilesEmailIsNull(): void\n {\n $userToSearch = $this->createMock(User::class);\n\n $salesforceUser = [\n 'Email' => null,\n ];\n\n app()->bind(QueryBuilder::class, function () {\n $queryBuilder = $this->createMock(QueryBuilder::class);\n $queryBuilder->expects($this->once())\n ->method('buildGetUsersQuery')\n ->with(null)\n ->willReturn('SELECT * FROM Users');\n\n return $queryBuilder;\n });\n\n $queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->exactly(2))\n ->method('query')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n\n $team = $this->createMock(Team::class);\n $user = $this->createMock(User::class);\n $this->mockTeamRepository($team, $salesforceUser, $user, false);\n\n $config = $this->createMock(Configuration::class);\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n\n $profile = $serviceMock->syncProfiles(null);\n\n $this->assertNull($profile);\n }\n\n public function testSyncProfilesUserToSearchMatchesCurrentUser(): void\n {\n $userToSearch = $this->createMock(User::class);\n $userToSearch->expects($this->once())\n ->method('getId')\n ->willReturn(123);\n\n $team = $this->createMock(Team::class);\n $config = $this->createMock(Configuration::class);\n $config->expects($this->once())\n ->method('getId')\n ->willReturn(1);\n\n $salesforceUser = [\n 'Email' => 'test@example.com',\n 'UserPreferencesLightningExperiencePreferred' => true,\n 'CallCenterId' => '123',\n 'Id' => '456',\n 'ProfileId' => '789',\n ];\n\n app()->bind(QueryBuilder::class, function () use ($userToSearch) {\n $queryBuilder = $this->createMock(QueryBuilder::class);\n $queryBuilder->expects($this->once())\n ->method('buildGetUsersQuery')\n ->with($userToSearch)\n ->willReturn('SELECT * FROM Users');\n\n return $queryBuilder;\n });\n\n $queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('query')\n ->with('SELECT * FROM Users')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n\n $user = $this->createMock(User::class);\n $user->expects($this->exactly(2))\n ->method('getId')\n ->willReturn(123);\n\n $this->mockTeamRepository($team, $salesforceUser, $user);\n\n $profileRepository = $this->createMock(ProfileRepository::class);\n $profileRepository->expects($this->once())\n ->method('updateOrCreateProfile')\n ->with(\n $user,\n [\n 'crm_configuration_id' => 1,\n 'crm_provider_id' => '456',\n ],\n [\n 'user_id' => 123,\n 'edition' => Profile::EDITION_LIGHTNING,\n 'has_external_cti' => true,\n 'crm_profile_id' => '789',\n ]\n )\n ->willReturn(new Profile());\n\n $this->app->instance(ProfileRepository::class, $profileRepository);\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n $profile = $serviceMock->syncProfiles($userToSearch);\n\n $this->assertInstanceOf(Profile::class, $profile);\n }\n\n public function testSyncProfilesWithCustomValidation(): void\n {\n $userToSearch = null;\n\n $team = $this->createMock(Team::class);\n $config = $this->createMockedConfiguration();\n $config->expects($this->atLeastOnce()) // Changed from once() to atLeastOnce()\n ->method('getId')\n ->willReturn(1);\n\n $salesforceUser = [\n 'Email' => 'test@example.com',\n 'UserPreferencesLightningExperiencePreferred' => true,\n 'CallCenterId' => '123',\n 'Id' => '456',\n 'ProfileId' => '789',\n 'CustomField' => 'CustomValue',\n ];\n\n $customRules = [\n ['field' => 'CustomField', 'value' => 'CustomValue'],\n ];\n\n $this->mockQueryBuilderAndHandler($userToSearch, [$salesforceUser]);\n\n $user = $this->createMock(User::class);\n $user->expects($this->once())\n ->method('getId')\n ->willReturn(123);\n\n $this->mockTeamRepository($team, $salesforceUser, $user, true, $customRules);\n\n $this->mockProfileRepository($user);\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n $profile = $serviceMock->syncProfiles($userToSearch);\n\n $this->assertNull($profile);\n }\n\n public function testSyncProfilesWithCustomValidationFailing(): void\n {\n $userToSearch = null;\n\n $team = $this->createMock(Team::class);\n $config = $this->createMockedConfiguration();\n\n $salesforceUser = [\n 'Email' => 'test@example.com',\n 'UserPreferencesLightningExperiencePreferred' => true,\n 'CallCenterId' => '123',\n 'Id' => '456',\n 'ProfileId' => '789',\n 'CustomField' => 'WrongValue',\n ];\n\n $customRules = [\n ['field' => 'CustomField', 'value' => 'CustomValue'],\n ];\n\n $this->mockQueryBuilderAndHandler($userToSearch, [$salesforceUser]);\n\n $teamRepository = $this->getMockForAbstractClass(TeamRepository::class, [], '', false, true, true, ['findActiveTeamMemberByEmail', 'getTeamSetting']);\n\n $teamSettings = $this->createMock(TeamSettings::class);\n $teamSettings->method('getValueType')\n ->willReturn('array');\n\n $teamSettings->method('getValue')\n ->willReturn(json_encode($customRules));\n\n $teamRepository->expects($this->once())\n ->method('getTeamSetting')\n ->with($team, 'custom_profile_validation')\n ->willReturn($teamSettings);\n\n app()->bind(TeamRepository::class, function () use ($teamRepository) {\n return $teamRepository;\n });\n\n $profileRepository = $this->createMock(ProfileRepository::class);\n $profileRepository->expects($this->never())\n ->method('updateOrCreateProfile');\n\n $this->app->instance(ProfileRepository::class, $profileRepository);\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n $result = $serviceMock->syncProfiles($userToSearch);\n\n $this->assertNull($result);\n }\n\n public function testGetContactRolesFromCrm(): void\n {\n $contactRoles = [\n [\n 'Id' => '1',\n 'ContactId' => 'Contact1',\n 'OpportunityId' => 'Opportunity1',\n 'Opportunity' => ['OwnerId' => 'Owner1'],\n 'IsPrimary' => true,\n 'Role' => 'Decision Maker',\n ],\n ];\n\n $expectedResponse = [\n [\n 'id' => '1',\n 'contactId' => 'Contact1',\n 'opportunityId' => 'Opportunity1',\n 'ownerId' => 'Owner1',\n 'isPrimary' => true,\n 'role' => 'Decision Maker',\n ],\n ];\n\n $this->bindQueryIterator($contactRoles);\n\n $serviceMock = $this->getServiceMock();\n\n $data = $serviceMock->getContactRolesFromCrm(now()->subDay());\n\n $this->assertEquals($expectedResponse, $data);\n }\n\n public function testGetContactRolesFromCrmNoResult(): void\n {\n $this->bindQueryIterator([]);\n\n $serviceMock = $this->getServiceMock();\n\n $data = $serviceMock->getContactRolesFromCrm(now()->subDay());\n\n $this->assertEquals([], $data);\n }\n\n public function testSyncContactRoles(): void\n {\n $contactRoles = [\n [\n 'id' => '1',\n 'contactId' => 'Contact1',\n 'opportunityId' => 'Opportunity1',\n 'ownerId' => 'Owner1',\n 'isPrimary' => true,\n 'role' => 'Decision Maker',\n ],\n ];\n\n app()->bind(ContactRoleRepository::class, function () {\n $contactRoleRepository = $this->createMock(ContactRoleRepository::class);\n $contactRoleRepository->expects($this->once())\n ->method('saveContactRoles');\n\n return $contactRoleRepository;\n });\n\n $serviceMock = $this->getServiceMock([\n 'getContactRolesFromCrm',\n 'syncRemotelyDeletedContactRoles',\n 'syncContact',\n 'syncOpportunity',\n ]);\n\n $config = $this->createMock(Configuration::class);\n $hasMany = $this->createMock(HasManyExtended::class);\n $hasMany->expects($this->exactly(2))\n ->method('where')\n ->willReturn($hasMany);\n\n $hasMany->expects($this->exactly(2))\n ->method('first')\n ->willReturn(\n $this->createMock(Contact::class),\n $this->createMock(Opportunity::class)\n );\n\n $config->expects($this->once())\n ->method('contacts')\n ->willReturn($hasMany);\n $config->expects($this->once())\n ->method('opportunities')\n ->willReturn($hasMany);\n\n $serviceMock->config = $config;\n\n $serviceMock->expects($this->once())\n ->method('getContactRolesFromCrm')\n ->willReturn($contactRoles);\n\n $serviceMock->expects($this->once())\n ->method('syncRemotelyDeletedContactRoles');\n\n $data = $serviceMock->syncContactRoles(now()->subDay());\n\n $this->assertEquals(1, $data);\n }\n\n public function testSyncRemotelyDeletedContactRoles(): void\n {\n $contactRoles = [\n [\n 'id' => '1',\n 'crm_provider_id' => '1',\n ],\n ];\n\n app()->bind(QueryHandler::class, function () use ($contactRoles) {\n $queryResults = new QueryResults($contactRoles, 1, true, null);\n\n $handler = $this->createMock(QueryHandler::class);\n $handler->method('queryDeleted')\n ->willReturn($queryResults);\n\n return $handler;\n });\n\n app()->bind(ContactRoleRepository::class, function () {\n $contactRoleRepository = $this->createMock(ContactRoleRepository::class);\n $contactRoleRepository->expects($this->once())\n ->method('deleteContactRoles');\n\n return $contactRoleRepository;\n });\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $this->createMock(Team::class);\n\n $data = $this->invokePrivateMethod('syncRemotelyDeletedContactRoles', $serviceMock, []);\n\n $this->assertTrue($data);\n }\n\n private function bindQueryIterator(array $queryResult): void\n {\n /** @var Client $client */\n $client = $this->createMock(Client::class);\n $queryIterator = new QueryIterator(\n $client,\n new QueryResults($queryResult, 1, true, null)\n );\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('query')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n }\n\n public static function getOpportunitySortOrderDataProvider(): array\n {\n return [\n 'all open recently updated' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED, ['LastModifiedDate DESC', true]],\n 'all open recently created' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED, ['CreatedDate DESC', true]],\n 'all open oldest created' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED, ['CreatedDate ASC', true]],\n 'all recently updated' => [Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED, ['LastModifiedDate DESC', false]],\n 'default' => ['unknown', ['LastModifiedDate DESC', true]],\n ];\n }\n\n private function createMockedConfiguration(): Configuration\n {\n $config = $this->createMock(Configuration::class);\n $profilesRelation = $this->getMockBuilder(\\Illuminate\\Database\\Eloquent\\Relations\\HasMany::class)\n ->disableOriginalConstructor()\n ->onlyMethods(['get'])\n ->addMethods(['where', 'first'])\n ->getMock();\n $profilesRelation->method('where')->willReturnSelf();\n $profilesRelation->method('get')->willReturn(collect([]));\n $profilesRelation->method('first')->willReturn(null);\n $config->method('profiles')->willReturn($profilesRelation);\n\n return $config;\n }\n\n private function getServiceMock(array $onlyMethods = []): MockObject&Service\n {\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $this->createMock(Client::class),\n $this->createMock(PayloadBuilder::class),\n $this->createMock(Dispatcher::class),\n $this->createMock(CountriesMap::class),\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods($onlyMethods)\n ->getMock();\n\n $serviceMock->profile = $this->createMock(Profile::class);\n\n return $serviceMock;\n }\n\n private function mockQueryBuilderAndHandler($userToSearch, $salesforceUsers): void\n {\n app()->bind(QueryBuilder::class, function () use ($userToSearch) {\n $queryBuilder = $this->createMock(QueryBuilder::class);\n $queryBuilder->expects($this->once())\n ->method('buildGetUsersQuery')\n ->with($userToSearch)\n ->willReturn('SELECT * FROM Users');\n\n return $queryBuilder;\n });\n\n $queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults($salesforceUsers, count($salesforceUsers), true, null));\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('query')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n }\n\n private function mockTeamRepository(\n Team $team,\n array $salesforceUser,\n ?User $user = null,\n bool $userSearch = true,\n array $customRules = []\n ): void {\n $teamRepository = $this->getMockForAbstractClass(TeamRepository::class, [], '', false, true, true, ['findActiveTeamMemberByEmail', 'getTeamSetting']);\n\n if ($userSearch) {\n $teamRepository->expects($this->once())\n ->method('findActiveTeamMemberByEmail')\n ->with($team, $salesforceUser['Email'])\n ->willReturn($user);\n }\n\n $teamSettings = $this->createMock(TeamSettings::class);\n $teamSettings->method('getValueType')\n ->willReturn('array');\n\n $teamSettings->method('getValue')\n ->willReturn(json_encode($customRules));\n\n $teamRepository->expects($this->once())\n ->method('getTeamSetting')\n ->with($team, 'custom_profile_validation')\n ->willReturn($teamSettings);\n\n app()->bind(TeamRepository::class, function () use ($teamRepository) {\n return $teamRepository;\n });\n }\n\n private function mockProfileRepository(User $user): void\n {\n $profileRepository = $this->createMock(ProfileRepository::class);\n $profileRepository->expects($this->once())\n ->method('updateOrCreateProfile')\n ->with(\n $user,\n [\n 'crm_configuration_id' => 1,\n 'crm_provider_id' => '456',\n ],\n [\n 'user_id' => 123,\n 'edition' => Profile::EDITION_LIGHTNING,\n 'has_external_cti' => true,\n 'crm_profile_id' => '789',\n ]\n )\n ->willReturn(new Profile());\n\n $this->app->instance(ProfileRepository::class, $profileRepository);\n }\n\n public function testBuildEnhancedNoteDecodesHtmlEntities(): void\n {\n $service = $this->getServiceMock(['createRecord']);\n\n $profile = new Profile();\n $profile->setAttribute('crm_provider_id', 'owner-123');\n $service->profile = $profile;\n\n $service->expects($this->exactly(2))\n ->method('createRecord')\n ->willReturnOnConsecutiveCalls('note-id-123', 'link-id-456');\n\n $bodyWithEntities = 'Welch's current challenges and Facebook's Club';\n\n $result = $this->invokePrivateMethod('buildEnhancedNote', $service, [\n 'Test Title',\n $bodyWithEntities,\n 'object-id-789',\n ]);\n\n $this->assertEquals('note-id-123', $result);\n }\n\n public function testBuildEnhancedNoteSanitizesWithoutQuotes(): void\n {\n $service = $this->getServiceMock(['createRecord']);\n\n $profile = new Profile();\n $profile->setAttribute('crm_provider_id', 'owner-456');\n $service->profile = $profile;\n\n $service->expects($this->exactly(2))\n ->method('createRecord')\n ->willReturnCallback(function ($type, $data) {\n if ($type === 'ContentNote') {\n $decoded = base64_decode($data['Content']);\n $this->assertStringContainsString(\"Welch's\", $decoded);\n $this->assertStringNotContainsString(''', $decoded);\n $this->assertStringNotContainsString('&#039;', $decoded);\n $this->assertStringContainsString('<script>', $decoded);\n\n return 'note-id-456';\n }\n\n return 'link-id-789';\n });\n\n $bodyWithMixedContent = \"Welch's and <script>alert('xss')</script>\";\n\n $result = $this->invokePrivateMethod('buildEnhancedNote', $service, [\n 'Test Title',\n $bodyWithMixedContent,\n 'object-id-123',\n ]);\n\n $this->assertEquals('note-id-456', $result);\n }\n\n public function testBuildEnhancedNoteConvertsLineBreaks(): void\n {\n $service = $this->getServiceMock(['createRecord']);\n\n $profile = new Profile();\n $profile->setAttribute('crm_provider_id', 'owner-789');\n $service->profile = $profile;\n\n $service->expects($this->exactly(2))\n ->method('createRecord')\n ->willReturnCallback(function ($type, $data) {\n if ($type === 'ContentNote') {\n $decoded = base64_decode($data['Content']);\n $this->assertStringContainsString('<br>', $decoded);\n $this->assertStringNotContainsString('<br />', $decoded);\n\n return 'note-id-789';\n }\n\n return 'link-id-012';\n });\n\n $bodyWithLineBreaks = \"Line 1\\nLine 2\\nLine 3\";\n\n $result = $this->invokePrivateMethod('buildEnhancedNote', $service, [\n 'Test Title',\n $bodyWithLineBreaks,\n 'object-id-456',\n ]);\n\n $this->assertEquals('note-id-789', $result);\n }\n\n public function testBuildEnhancedNoteHandlesComplexScenario(): void\n {\n $service = $this->getServiceMock(['createRecord']);\n\n $profile = new Profile();\n $profile->setAttribute('crm_provider_id', 'owner-complex');\n $service->profile = $profile;\n\n $service->expects($this->exactly(2))\n ->method('createRecord')\n ->willReturnCallback(function ($type, $data) {\n if ($type === 'ContentNote') {\n $decoded = base64_decode($data['Content']);\n\n $this->assertStringContainsString(\"Welch's\", $decoded);\n $this->assertStringContainsString(\"Facebook's Club\", $decoded);\n $this->assertStringContainsString(\"Arctics'\", $decoded);\n $this->assertStringNotContainsString(''', $decoded);\n $this->assertStringNotContainsString('&#039;', $decoded);\n $this->assertStringContainsString('<br>', $decoded);\n $this->assertStringContainsString('<', $decoded);\n $this->assertStringContainsString('>', $decoded);\n\n return 'note-complex';\n }\n\n return 'link-complex';\n });\n\n $complexBody = \"Summary:\\n---------\\nThe call focused on understanding Welch's current challenges and exploring how Arctics' Revenue Growth Management solutions could support their strategic goals.\\n\\n• John SMith discussed his role as a category advisor for Google and Facebook's Club, emphasizing the importance of market research and advising on product assortment.\\n• Madona introduced Arctics' Virtual Shoppers AI, which simulates consumer <behavior> to optimize pricing and promotional strategies.\";\n\n $result = $this->invokePrivateMethod('buildEnhancedNote', $service, [\n 'Jiminny Transcription Summary',\n $complexBody,\n 'task-id-001',\n ]);\n\n $this->assertEquals('note-complex', $result);\n }\n\n public function testSyncRemotelyDeletedObjectsWithErrorHandlingSuccess(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['syncRemotelyDeletedObjects'])\n ->getMock();\n\n // Mock team\n $team = $this->createMock(Team::class);\n $team->method('getUuid')->willReturn('team-uuid-123');\n $serviceMock->team = $team;\n\n // Expect syncRemotelyDeletedObjects to be called once and succeed\n $serviceMock->expects($this->once())\n ->method('syncRemotelyDeletedObjects')\n ->with(\\Jiminny\\Enums\\CrmObject::ACCOUNT);\n\n // Call the protected method using reflection\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');\n $method->setAccessible(true);\n\n // Should not throw any exceptions\n $method->invoke($serviceMock, \\Jiminny\\Enums\\CrmObject::ACCOUNT);\n\n $this->assertTrue(true); // Test completed successfully\n }\n\n public function testSyncRemotelyDeletedObjectsWithErrorHandlingFailure(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['syncRemotelyDeletedObjects'])\n ->getMock();\n\n // Mock team\n $team = $this->createMock(Team::class);\n $team->method('getUuid')->willReturn('team-uuid-456');\n $serviceMock->team = $team;\n\n // Mock logger to verify warning is logged\n $logger = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n\n // Use reflection to set the protected logger property\n $reflection = new \\ReflectionClass($serviceMock);\n $loggerProperty = $reflection->getProperty('logger');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($serviceMock, $logger);\n\n $exception = new \\Exception('Sync failed due to API error');\n\n // Expect syncRemotelyDeletedObjects to throw an exception\n $serviceMock->expects($this->once())\n ->method('syncRemotelyDeletedObjects')\n ->with(\\Jiminny\\Enums\\CrmObject::CONTACT)\n ->willThrowException($exception);\n\n // Expect warning to be logged with correct message and parameters\n $logger->expects($this->once())\n ->method('warning')\n ->with(\n '[Salesforce] Remotely deleted objects sync failed',\n [\n 'objectType' => 'contact',\n 'teamId' => 'team-uuid-456',\n 'reason' => 'Sync failed due to API error',\n ]\n );\n\n // Call the protected method using reflection\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');\n $method->setAccessible(true);\n\n // Should not re-throw the exception, just log it\n $method->invoke($serviceMock, \\Jiminny\\Enums\\CrmObject::CONTACT);\n\n $this->assertTrue(true); // Test completed successfully\n }\n\n public function testSyncRemotelyDeletedObjectsWithErrorHandlingWithLogParams(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['syncRemotelyDeletedObjects'])\n ->getMock();\n\n // Mock team\n $team = $this->createMock(Team::class);\n $team->method('getUuid')->willReturn('team-uuid-789');\n $serviceMock->team = $team;\n\n // Mock logger to verify warning is logged\n $logger = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n\n // Use reflection to set the protected logger property\n $loggerReflection = new \\ReflectionClass($serviceMock);\n $loggerProperty = $loggerReflection->getProperty('logger');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($serviceMock, $logger);\n\n $exception = new \\Exception('Network timeout');\n\n // Expect syncRemotelyDeletedObjects to throw an exception\n $serviceMock->expects($this->once())\n ->method('syncRemotelyDeletedObjects')\n ->with(\\Jiminny\\Enums\\CrmObject::OPPORTUNITY)\n ->willThrowException($exception);\n\n // Additional log parameters\n $logParams = [\n 'syncType' => 'full',\n 'batchSize' => 100,\n ];\n\n // Expect warning to be logged with merged parameters\n $logger->expects($this->once())\n ->method('warning')\n ->with(\n '[Salesforce] Remotely deleted objects sync failed',\n [\n 'objectType' => 'opportunity',\n 'teamId' => 'team-uuid-789',\n 'reason' => 'Network timeout',\n 'syncType' => 'full',\n 'batchSize' => 100,\n ]\n );\n\n // Call the protected method using reflection\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');\n $method->setAccessible(true);\n\n // Should not re-throw the exception, just log it\n $method->invoke($serviceMock, \\Jiminny\\Enums\\CrmObject::OPPORTUNITY, $logParams);\n\n $this->assertTrue(true); // Test completed successfully\n }\n\n /**\n * @dataProvider crmObjectProvider\n */\n public function testSyncRemotelyDeletedObjectsWithErrorHandlingDifferentCrmObjects(\\Jiminny\\Enums\\CrmObject $crmObject): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['syncRemotelyDeletedObjects'])\n ->getMock();\n\n // Mock team\n $team = $this->createMock(Team::class);\n $team->method('getUuid')->willReturn('team-uuid-test');\n $serviceMock->team = $team;\n\n // Mock logger to verify warning is logged\n $logger = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n\n // Use reflection to set the protected logger property\n $loggerReflectionClass = new \\ReflectionClass($serviceMock);\n $loggerProperty = $loggerReflectionClass->getProperty('logger');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($serviceMock, $logger);\n\n $exception = new \\Exception('Test error');\n\n // Expect syncRemotelyDeletedObjects to throw an exception\n $serviceMock->expects($this->once())\n ->method('syncRemotelyDeletedObjects')\n ->with($crmObject)\n ->willThrowException($exception);\n\n // Expect warning to be logged with correct entity type\n $logger->expects($this->once())\n ->method('warning')\n ->with(\n '[Salesforce] Remotely deleted objects sync failed',\n [\n 'objectType' => $crmObject->value,\n 'teamId' => 'team-uuid-test',\n 'reason' => 'Test error',\n ]\n );\n\n // Call the protected method using reflection\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');\n $method->setAccessible(true);\n\n $method->invoke($serviceMock, $crmObject);\n\n $this->assertTrue(true); // Test completed successfully\n }\n\n public function testHandleObjectDeletionWithDeletedEntity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['deleteCrmObject'])\n ->getMock();\n\n $entity = $this->createMock(\\Jiminny\\Models\\Account::class);\n $crmData = ['IsDeleted' => true];\n\n $serviceMock->expects($this->once())\n ->method('deleteCrmObject')\n ->with($entity);\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('handleObjectDeletion');\n $method->setAccessible(true);\n\n $method->invoke($serviceMock, $entity, $crmData);\n }\n\n public function testHandleObjectDeletionWithNonDeletedEntity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['deleteCrmObject'])\n ->getMock();\n\n $entity = $this->createMock(\\Jiminny\\Models\\Contact::class);\n $crmData = ['IsDeleted' => false];\n\n $serviceMock->expects($this->never())\n ->method('deleteCrmObject');\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('handleObjectDeletion');\n $method->setAccessible(true);\n\n $method->invoke($serviceMock, $entity, $crmData);\n }\n\n public function testDeleteCrmObjectWithValidEntity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['dispatchDeleteCrmObjectJob'])\n ->getMock();\n\n $entity = $this->createMock(\\Jiminny\\Models\\Lead::class);\n $entity->expects($this->once())->method('delete');\n\n $serviceMock->expects($this->once())\n ->method('dispatchDeleteCrmObjectJob')\n ->with($entity);\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('deleteCrmObject');\n $method->setAccessible(true);\n\n $method->invoke($serviceMock, $entity);\n }\n\n public function testDeleteCrmObjectWithNullEntity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['dispatchDeleteCrmObjectJob'])\n ->getMock();\n\n $serviceMock->expects($this->never())\n ->method('dispatchDeleteCrmObjectJob');\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('deleteCrmObject');\n $method->setAccessible(true);\n\n $method->invoke($serviceMock, null);\n }\n\n public function testDispatchDeleteCrmObjectJobWithNullEntity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($service);\n $method = $reflection->getMethod('dispatchDeleteCrmObjectJob');\n $method->setAccessible(true);\n\n // Should return early without dispatching - no exception expected\n $method->invoke($service, null);\n\n $this->assertTrue(true); // Test completed successfully\n }\n\n public function testDispatchDeleteCrmObjectJobWithUnsupportedEntity(): void\n {\n $this->expectException(\\TypeError::class);\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n $unsupportedEntity = $this->createMock(\\stdClass::class);\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($service);\n $method = $reflection->getMethod('dispatchDeleteCrmObjectJob');\n\n // This will throw TypeError due to union type constraint\n $method->invoke($service, $unsupportedEntity);\n }\n\n public function testHandleEntityDeletionByProviderIdMethodExists(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n // Test that the method exists and is accessible via reflection\n $reflection = new \\ReflectionClass($service);\n $method = $reflection->getMethod('handleEntityDeletionByProviderId');\n $method->setAccessible(true);\n\n // Verify method exists and has correct parameters\n $this->assertTrue($method->isProtected());\n $this->assertEquals(2, $method->getNumberOfParameters());\n\n $parameters = $method->getParameters();\n $this->assertEquals('targetEntity', $parameters[0]->getName());\n $this->assertEquals('crmData', $parameters[1]->getName());\n }\n\n public function testSyncRemotelyDeletedObjectsWithNoResults(): void\n {\n // Create a real service instance to avoid mock property issues\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n // Mock queryHandler to throw NoResultsException\n $queryHandler = $this->createMock(QueryHandler::class);\n $queryHandler->expects($this->once())\n ->method('queryDeleted')\n ->with('Opportunity')\n ->willThrowException(new NoResultsException('No results'));\n\n // Set the queryHandler using reflection on the real service\n $reflection = new \\ReflectionClass($service);\n $queryHandlerProperty = $reflection->getProperty('queryHandler');\n $queryHandlerProperty->setAccessible(true);\n $queryHandlerProperty->setValue($service, $queryHandler);\n\n $result = self::invokePrivateMethod('syncRemotelyDeletedObjects', $service, [CrmObject::OPPORTUNITY]);\n\n $this->assertFalse($result);\n }\n\n public function testSyncRemotelyDeletedObjectsWithEmptyResults(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n // Mock queryHandler to return empty results\n $queryResult = $this->createMock(QueryResults::class);\n $queryResult->method('getResults')->willReturn([]);\n\n $queryHandler = $this->createMock(QueryHandler::class);\n $queryHandler->expects($this->once())\n ->method('queryDeleted')\n ->with('Opportunity')\n ->willReturn($queryResult);\n\n // Set the queryHandler using reflection on the real service\n $reflection = new \\ReflectionClass($service);\n $queryHandlerProperty = $reflection->getProperty('queryHandler');\n $queryHandlerProperty->setAccessible(true);\n $queryHandlerProperty->setValue($service, $queryHandler);\n\n $result = self::invokePrivateMethod('syncRemotelyDeletedObjects', $service, [CrmObject::OPPORTUNITY]);\n\n $this->assertFalse($result);\n }\n\n public function testSyncRemotelyDeletedObjectsWithUnsupportedCrmObject(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n // Mock queryHandler to return some deleted objects so we reach the match statement\n $deletedObjects = [\n ['id' => 'task1'],\n ['id' => 'task2'],\n ];\n $queryResult = $this->createMock(QueryResults::class);\n $queryResult->method('getResults')->willReturn($deletedObjects);\n\n $queryHandler = $this->createMock(QueryHandler::class);\n $queryHandler->expects($this->once())\n ->method('queryDeleted')\n ->with('Task') // ucfirst('task') = 'Task'\n ->willReturn($queryResult);\n\n self::setPrivateProperty($service, 'queryHandler', $queryHandler);\n\n $this->expectException(InvalidArgumentException::class);\n $this->expectExceptionMessage('Unsupported CrmObject: task');\n\n self::invokePrivateMethod('syncRemotelyDeletedObjects', $service, [CrmObject::TASK]);\n }\n\n public static function crmObjectProvider(): array\n {\n return [\n 'Account' => [CrmObject::ACCOUNT],\n 'Contact' => [CrmObject::CONTACT],\n 'Lead' => [CrmObject::LEAD],\n 'Opportunity' => [CrmObject::OPPORTUNITY],\n ];\n }\n\n public function testVerifyTaskExistsReturnsTrue(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-123', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-123');\n $activity->method('getId')->willReturn(456);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-123', ['Id', 'IsDeleted'])\n ->willReturn(['Id' => 'task-123', 'IsDeleted' => false]);\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertTrue($result);\n }\n\n public function testVerifyTaskExistsReturnsTrueForEvent(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:event-123', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('event-123');\n $activity->method('getId')->willReturn(456);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_EVENT);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Event', 'event-123', ['Id', 'IsDeleted'])\n ->willReturn(['Id' => 'event-123', 'IsDeleted' => false]);\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertTrue($result);\n }\n\n public function testVerifyTaskExistsReturnsFalseWhenDeleted(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-456', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-456');\n $activity->method('getId')->willReturn(789);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-456', ['Id', 'IsDeleted'])\n ->willReturn(['Id' => 'task-456', 'IsDeleted' => true]);\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertFalse($result);\n }\n\n public function testVerifyTaskExistsReturnsFalseWhenNotFound(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-999', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-999');\n $activity->method('getId')->willReturn(999);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-999', ['Id', 'IsDeleted'])\n ->willThrowException(new \\Jiminny\\Exceptions\\HttpNotFoundException('Task not found'));\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertFalse($result);\n }\n\n public function testVerifyTaskExistsReturnsFalseWhenNoPlaybook(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-no-playbook', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-no-playbook');\n $activity->method('getId')->willReturn(111);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn(null);\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertFalse($result);\n }\n\n public function testVerifyTaskExistsThrowsExceptionForTransientErrors(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-error', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-error');\n $activity->method('getId')->willReturn(888);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-error', ['Id', 'IsDeleted'])\n ->willThrowException(new \\RuntimeException('Network timeout'));\n\n $this->expectException(\\RuntimeException::class);\n $this->expectExceptionMessage('Network timeout');\n\n $service->verifyTaskExists($activity);\n }\n\n public function testVerifyTaskExistsCachesResults(): void\n {\n $cachedValue = null;\n Cache::shouldReceive('remember')\n ->twice()\n ->with('crm_task_exists:123:task-cached', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(function ($key, $ttl, $callback) use (&$cachedValue) {\n if ($cachedValue === null) {\n $cachedValue = $callback();\n }\n\n return $cachedValue;\n });\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-cached');\n $activity->method('getId')->willReturn(555);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-cached', ['Id', 'IsDeleted'])\n ->willReturn(['Id' => 'task-cached', 'IsDeleted' => false]);\n\n $result1 = $service->verifyTaskExists($activity);\n $result2 = $service->verifyTaskExists($activity);\n\n $this->assertTrue($result1);\n $this->assertTrue($result2);\n }\n\n public function testVerifyTaskExistsReturnsFalseForHttpBadRequestException(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-400', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-400');\n $activity->method('getId')->willReturn(400);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-400', ['Id', 'IsDeleted'])\n ->willThrowException(new \\Jiminny\\Exceptions\\HttpBadRequestException('Bad request'));\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertFalse($result);\n }\n\n public function testImportOpportunitySkipsWhenNoProfileAndNoAccount(): void\n {\n $crmData = [\n 'Id' => 'SF-NO-USER-1',\n 'Name' => 'Test Opportunity',\n 'OwnerId' => 'owner-no-profile',\n // No AccountId\n ];\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(\\Illuminate\\Events\\Dispatcher::class); // ← ADD THIS\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n\n $service = new Service(\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService\n );\n\n $config = $this->createMock(Configuration::class);\n\n // Mock profiles relation returning null (no profile found)\n $profilesRelation = \\Mockery::mock(\\Illuminate\\Database\\Eloquent\\Relations\\HasMany::class);\n $profilesRelation->shouldReceive('where')->with('crm_provider_id', 'owner-no-profile')->andReturnSelf();\n $profilesRelation->shouldReceive('first')->andReturn(null);\n\n $config->expects($this->once())\n ->method('profiles')\n ->willReturn($profilesRelation);\n\n $team = $this->createMock(Team::class);\n $team->method('getId')->willReturn(1);\n\n $logger = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n $logger->expects($this->once())\n ->method('error')\n ->with(\n '[Salesforce] | Skip import, no user_id found',\n ['id' => 'SF-NO-USER-1']\n );\n\n $reflection = new \\ReflectionClass($service);\n\n $configProperty = $reflection->getProperty('config');\n $configProperty->setAccessible(true);\n $configProperty->setValue($service, $config);\n\n $teamProperty = $reflection->getProperty('team');\n $teamProperty->setAccessible(true);\n $teamProperty->setValue($service, $team);\n\n $loggerProperty = $reflection->getProperty('logger');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($service, $logger);\n\n // Initialize profile property to avoid \"must not be accessed before initialization\" error\n $profileProperty = $reflection->getProperty('profile');\n $profileProperty->setAccessible(true);\n $profileProperty->setValue($service, null);\n\n $result = self::invokePrivateMethod('importOpportunity', $service, [$crmData]);\n\n $this->assertNull($result);\n }\n\n public function testImportOpportunitySkipsWhenProfileNotFound(): void\n {\n $crmData = [\n 'Id' => 'SF-NO-USER-2',\n 'Name' => 'Test Opportunity',\n 'OwnerId' => 'owner-not-found',\n // No AccountId - avoid complex account processing\n ];\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $eventDispatcher = $this->createMock(\\Illuminate\\Events\\Dispatcher::class); // ← ADD THIS\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n\n\n $service = new Service(\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService\n );\n\n $config = $this->createMock(Configuration::class);\n\n // Mock profiles relation returning null (no profile found)\n $profilesRelation = \\Mockery::mock(\\Illuminate\\Database\\Eloquent\\Relations\\HasMany::class);\n $profilesRelation->shouldReceive('where')->with('crm_provider_id', 'owner-not-found')->andReturnSelf();\n $profilesRelation->shouldReceive('first')->andReturn(null);\n\n $config->expects($this->once())\n ->method('profiles')\n ->willReturn($profilesRelation);\n\n $team = $this->createMock(Team::class);\n $team->method('getId')->willReturn(1);\n\n $logger = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n $logger->expects($this->once())\n ->method('error')\n ->with(\n '[Salesforce] | Skip import, no user_id found',\n ['id' => 'SF-NO-USER-2']\n );\n\n $reflection = new \\ReflectionClass($service);\n\n $configProperty = $reflection->getProperty('config');\n $configProperty->setAccessible(true);\n $configProperty->setValue($service, $config);\n\n $teamProperty = $reflection->getProperty('team');\n $teamProperty->setAccessible(true);\n $teamProperty->setValue($service, $team);\n\n $loggerProperty = $reflection->getProperty('logger');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($service, $logger);\n\n // Initialize profile property\n $profileProperty = $reflection->getProperty('profile');\n $profileProperty->setAccessible(true);\n $profileProperty->setValue($service, null);\n\n $result = self::invokePrivateMethod('importOpportunity', $service, [$crmData]);\n\n $this->assertNull($result);\n }\n}\n\nclass HasManyExtended extends HasMany\n{\n public function where()\n {\n }\n\n public function first()\n {\n }\n}","depth":4,"bounds":{"left":0.16223404,"top":0.09736632,"width":0.3550532,"height":0.90263367},"on_screen":true,"value":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Services\\Crm\\Salesforce;\n\nuse Illuminate\\Database\\Eloquent\\Relations\\HasMany;\nuse Illuminate\\Events\\Dispatcher;\nuse Illuminate\\Support\\Facades\\Cache;\nuse Jiminny\\Component\\Country\\CountriesMap;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Enums\\CrmObject;\nuse Jiminny\\Exceptions\\InvalidArgumentException;\nuse Jiminny\\Exceptions\\NoResultsException;\nuse Jiminny\\Integrations\\PlaybookResolver;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Crm\\Configuration;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\TeamSettings;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\ContactRoleRepository;\nuse Jiminny\\Repositories\\Crm\\FieldDataRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\Salesforce\\Client;\nuse Jiminny\\Services\\Crm\\Salesforce\\PayloadBuilder;\nuse Jiminny\\Services\\Crm\\Salesforce\\QueryBuilder;\nuse Jiminny\\Services\\Crm\\Salesforce\\QueryHandler;\nuse Jiminny\\Services\\Crm\\Salesforce\\QueryIterator;\nuse Jiminny\\Services\\Crm\\Salesforce\\QueryResults;\nuse Jiminny\\Services\\Crm\\Salesforce\\Service;\nuse PHPUnit\\Framework\\MockObject\\MockObject;\nuse Tests\\TestCase;\nuse Tests\\Unit\\Traits\\TestPrivateMethod;\n\nclass ServiceTest extends TestCase\n{\n use TestPrivateMethod;\n\n public function testFetchAndAssociateRelatedActivity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $payloadBuilder->method('addCustomLogicFieldsPayload')\n ->willReturnCallback(function ($activity, $payload) {\n return $payload;\n });\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods(['fetchRelatedActivity', 'getPlaybook', 'getPlaybookCategory', 'updateRecord'])\n ->getMock();\n\n $serviceMock->expects($this->once())\n ->method('fetchRelatedActivity')\n ->willReturn([\n 'Id' => 'testId',\n 'Type' => null,\n 'OwnerId' => 'testerUser',\n 'Description' => 'Test description',\n ]);\n\n $user = $this->createMock(User::class);\n $team = $this->createMock(Team::class);\n $user->method('getAttribute')->with('team')->willReturn($team);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityField')->willReturn(null);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_EVENT);\n\n $serviceMock->expects($this->once())\n ->method('getPlaybook')\n ->with($user)\n ->willReturn($playbook);\n\n $serviceMock->expects($this->never())\n ->method('getPlaybookCategory');\n\n $serviceMock->expects($this->never())\n ->method('updateRecord');\n\n $fieldDataRepository = $this->createMock(FieldDataRepository::class);\n $fieldDataRepository->method('getActivityFieldData')->willReturn(collect([]));\n app()->instance(FieldDataRepository::class, $fieldDataRepository);\n\n $config = $this->createMock(Configuration::class);\n $profilesRelation = $this->getMockBuilder(\\Illuminate\\Database\\Eloquent\\Relations\\HasMany::class)\n ->disableOriginalConstructor()\n ->onlyMethods(['get'])\n ->addMethods(['where'])\n ->getMock();\n $profilesRelation->method('where')->willReturnSelf();\n $profilesRelation->method('get')->willReturn(collect([]));\n $config->method('profiles')->willReturn($profilesRelation);\n\n $serviceMock->config = $config;\n $serviceMock->profile = null;\n\n $actualStartTime = \\Carbon\\Carbon::now();\n\n $activity = $this->getMockBuilder(Activity::class)\n ->disableOriginalConstructor()\n ->onlyMethods(['update', 'hasProspect'])\n ->getMock();\n\n $activity->method('update')->willReturn(true);\n $activity->method('hasProspect')->willReturn(true);\n\n $activity->type = Activity::TYPE_CONFERENCE;\n $activity->provider = Activity::PROVIDER_TWILIO;\n $activity->lead_id = 1;\n $activity->user_id = 0;\n $activity->id_string = 'test-activity-id';\n $activity->user = $user;\n\n $activity->actual_start_time = $actualStartTime;\n $activity->uuid = 'c53d8320-f556-4cee-a2f8-5f232f454ca4';\n\n app()->bind(PlaybookResolver::class, function () use ($user) {\n $playbook = $this->createMock(Playbook::class);\n $playbookResolver = $this->createMock(PlaybookResolver::class);\n $playbookResolver->expects($this->once())\n ->method('resolvePlaybookByUser')\n ->with($user)\n ->willReturn($playbook);\n\n return $playbookResolver;\n });\n\n $data = $serviceMock->fetchAndAssociateRelatedActivity($activity);\n\n $this->assertInstanceOf(Activity::class, $data);\n $this->assertEquals(Activity::TYPE_CONFERENCE, $data->getType());\n $this->assertEquals($actualStartTime->getTimestamp(), $data->getActualStartTime()->getTimestamp());\n }\n\n public function testFetchAndAssociateRelatedActivitySkipsForTaskBasedPlaybook(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods(['fetchRelatedActivity', 'getPlaybook'])\n ->getMock();\n\n $user = $this->createMock(User::class);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n $playbook->method('getId')->willReturn(123);\n\n $serviceMock->expects($this->once())\n ->method('getPlaybook')\n ->with($user)\n ->willReturn($playbook);\n\n $serviceMock->expects($this->never())\n ->method('fetchRelatedActivity');\n\n $activity = $this->getMockBuilder(Activity::class)\n ->disableOriginalConstructor()\n ->onlyMethods(['hasProspect', 'getUuid'])\n ->getMock();\n\n $activity->method('hasProspect')->willReturn(true);\n $activity->method('getUuid')->willReturn('c53d8320-f556-4cee-a2f8-5f232f454ca4');\n $activity->type = Activity::TYPE_CONFERENCE;\n $activity->actual_start_time = \\Carbon\\Carbon::now();\n $activity->user = $user;\n\n $result = $serviceMock->fetchAndAssociateRelatedActivity($activity);\n\n $this->assertNull($result);\n }\n\n public function testFetchAndAssociateRelatedActivityReturnsNullForNonConference(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $serviceMock = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)\n );\n\n $activity = $this->getMockBuilder(Activity::class)\n ->disableOriginalConstructor()\n ->getMock();\n\n $activity->type = Activity::TYPE_SOFTPHONE;\n\n $result = $serviceMock->fetchAndAssociateRelatedActivity($activity);\n\n $this->assertNull($result);\n }\n\n public function testFetchAndAssociateRelatedActivityReturnsNullWhenNoStartTime(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $serviceMock = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)\n );\n\n\n $activity = $this->getMockBuilder(Activity::class)\n ->disableOriginalConstructor()\n ->getMock();\n\n $activity->type = Activity::TYPE_CONFERENCE;\n $activity->actual_start_time = null;\n $activity->scheduled_start_time = null;\n\n $result = $serviceMock->fetchAndAssociateRelatedActivity($activity);\n\n $this->assertNull($result);\n }\n\n public function testFetchAndAssociateRelatedActivityReturnsNullWhenNoProspect(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods(['getPlaybook'])\n ->getMock();\n\n $serviceMock->expects($this->never())\n ->method('getPlaybook');\n\n $activity = $this->getMockBuilder(Activity::class)\n ->disableOriginalConstructor()\n ->onlyMethods(['hasProspect', 'getUuid'])\n ->getMock();\n\n $activity->method('hasProspect')->willReturn(false);\n $activity->method('getUuid')->willReturn('c53d8320-f556-4cee-a2f8-5f232f454ca4');\n $activity->type = Activity::TYPE_CONFERENCE;\n $activity->actual_start_time = \\Carbon\\Carbon::now();\n\n $result = $serviceMock->fetchAndAssociateRelatedActivity($activity);\n\n $this->assertNull($result);\n }\n\n public function testMatchExactlyByEmail(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods([])\n ->getMock();\n\n $profile = new Profile();\n $profile->setAttribute('id', bin2hex(random_bytes(8)));\n $serviceMock->profile = $profile;\n\n $team = $this->createMock(Team::class);\n $serviceMock->team = $team;\n\n $data = $serviceMock->matchExactlyByEmail(bin2hex(random_bytes(8)) . 'test_email@testserver.com');\n\n $this->assertEquals(null, $data);\n }\n\n public function testMatchDomainFromEmail(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $queryIterator = $this->createMock(QueryIterator::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $config = $this->createMock(Configuration::class);\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('search')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods(['convertCrmData'])\n ->getMock();\n\n $profile = new Profile();\n $profile->account_fields = 'Field1, Field2, Field3';\n $serviceMock->profile = $profile;\n\n $serviceMock->expects($this->once())\n ->method('convertCrmData')\n ->willReturn(['test']);\n\n $this->app->bind(QueryBuilder::class, function () {\n $queryBuilder = $this->createMock(QueryBuilder::class);\n $queryBuilder->expects($this->once())\n ->method('buildMatchByDomainQuery')\n ->with('test_email@testserver.com')\n ->willReturn('FIND {test_email@testserver.com} IN ALL FIELDS RETURNING Account(Id)');\n\n return $queryBuilder;\n });\n\n $team = $this->createMock(Team::class);\n\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n\n $data = $serviceMock->matchByDomain('test_email@testserver.com');\n\n $this->assertEquals(['test'], $data);\n }\n\n public function testBuildTaskSearchFields(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)\n );\n\n $fields = $service->buildTaskSearchFields();\n\n $expectedFields = ['Id', 'WhoId', 'WhatId', 'AccountId'];\n\n $this->assertEquals($expectedFields, $fields);\n }\n\n public function testMapCrmObjects(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class),\n );\n\n $sampleTask = [\n 'WhoId' => '003sampleWhoId',\n 'AccountId' => 'sampleAccountId',\n 'WhatId' => 'sampleWhatId',\n ];\n\n $activityData = $service->mapCrmObjects($sampleTask);\n\n $expectedActivityData = [\n 'contact' => '003sampleWhoId',\n 'account' => 'sampleAccountId',\n 'opportunity' => 'sampleWhatId',\n ];\n\n $this->assertEquals($expectedActivityData, $activityData);\n }\n\n public function testGetInstalledAppVersion(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n $queryIterator = $this->createMock(QueryIterator::class);\n $queryIterator->expects($this->any())\n ->method('current')->willReturn([\n 'SubscriberPackageVersion' => [\n 'MajorVersion' => '1',\n 'MinorVersion' => '0',\n 'PatchVersion' => '1',\n 'BuildNumber' => '0',\n ],\n ]);\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('metadata')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods(array_diff(get_class_methods(Service::class), ['getInstalledAppVersion']))\n ->getMock();\n\n $version = $serviceMock->getInstalledAppVersion();\n\n $this->assertEquals('1010', $version);\n }\n\n public function testSyncProfiles(): void\n {\n $userToSearch = null;\n\n $team = $this->createMock(Team::class);\n $config = $this->createMockedConfiguration();\n $config->expects($this->once())\n ->method('getId')\n ->willReturn(1);\n\n $salesforceUser = [\n 'Email' => 'test@example.com',\n 'UserPreferencesLightningExperiencePreferred' => true,\n 'CallCenterId' => '123',\n 'Id' => '456',\n 'ProfileId' => '789',\n ];\n\n app()->bind(QueryBuilder::class, function () use ($userToSearch) {\n $queryBuilder = $this->createMock(QueryBuilder::class);\n $queryBuilder->expects($this->once())\n ->method('buildGetUsersQuery')\n ->with($userToSearch)\n ->willReturn('SELECT * FROM Users');\n\n return $queryBuilder;\n });\n\n $queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('query')\n ->with('SELECT * FROM Users')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n\n $user = $this->createMock(User::class);\n $user->expects($this->once())\n ->method('getId')\n ->willReturn(123);\n\n $this->mockTeamRepository($team, $salesforceUser, $user);\n\n $profileRepository = $this->createMock(ProfileRepository::class);\n $profileRepository->expects($this->once())\n ->method('updateOrCreateProfile')\n ->with(\n $user,\n [\n 'crm_configuration_id' => 1,\n 'crm_provider_id' => '456',\n ],\n [\n 'user_id' => 123,\n 'edition' => Profile::EDITION_LIGHTNING,\n 'has_external_cti' => true,\n 'crm_profile_id' => '789',\n ]\n )\n ->willReturn(new Profile());\n\n $this->app->instance(ProfileRepository::class, $profileRepository);\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n $result = $serviceMock->syncProfiles($userToSearch);\n\n $this->assertNull($result);\n }\n\n public function testSyncProfilesEmailIsNull(): void\n {\n $userToSearch = $this->createMock(User::class);\n\n $salesforceUser = [\n 'Email' => null,\n ];\n\n app()->bind(QueryBuilder::class, function () {\n $queryBuilder = $this->createMock(QueryBuilder::class);\n $queryBuilder->expects($this->once())\n ->method('buildGetUsersQuery')\n ->with(null)\n ->willReturn('SELECT * FROM Users');\n\n return $queryBuilder;\n });\n\n $queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->exactly(2))\n ->method('query')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n\n $team = $this->createMock(Team::class);\n $user = $this->createMock(User::class);\n $this->mockTeamRepository($team, $salesforceUser, $user, false);\n\n $config = $this->createMock(Configuration::class);\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n\n $profile = $serviceMock->syncProfiles(null);\n\n $this->assertNull($profile);\n }\n\n public function testSyncProfilesUserToSearchMatchesCurrentUser(): void\n {\n $userToSearch = $this->createMock(User::class);\n $userToSearch->expects($this->once())\n ->method('getId')\n ->willReturn(123);\n\n $team = $this->createMock(Team::class);\n $config = $this->createMock(Configuration::class);\n $config->expects($this->once())\n ->method('getId')\n ->willReturn(1);\n\n $salesforceUser = [\n 'Email' => 'test@example.com',\n 'UserPreferencesLightningExperiencePreferred' => true,\n 'CallCenterId' => '123',\n 'Id' => '456',\n 'ProfileId' => '789',\n ];\n\n app()->bind(QueryBuilder::class, function () use ($userToSearch) {\n $queryBuilder = $this->createMock(QueryBuilder::class);\n $queryBuilder->expects($this->once())\n ->method('buildGetUsersQuery')\n ->with($userToSearch)\n ->willReturn('SELECT * FROM Users');\n\n return $queryBuilder;\n });\n\n $queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('query')\n ->with('SELECT * FROM Users')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n\n $user = $this->createMock(User::class);\n $user->expects($this->exactly(2))\n ->method('getId')\n ->willReturn(123);\n\n $this->mockTeamRepository($team, $salesforceUser, $user);\n\n $profileRepository = $this->createMock(ProfileRepository::class);\n $profileRepository->expects($this->once())\n ->method('updateOrCreateProfile')\n ->with(\n $user,\n [\n 'crm_configuration_id' => 1,\n 'crm_provider_id' => '456',\n ],\n [\n 'user_id' => 123,\n 'edition' => Profile::EDITION_LIGHTNING,\n 'has_external_cti' => true,\n 'crm_profile_id' => '789',\n ]\n )\n ->willReturn(new Profile());\n\n $this->app->instance(ProfileRepository::class, $profileRepository);\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n $profile = $serviceMock->syncProfiles($userToSearch);\n\n $this->assertInstanceOf(Profile::class, $profile);\n }\n\n public function testSyncProfilesWithCustomValidation(): void\n {\n $userToSearch = null;\n\n $team = $this->createMock(Team::class);\n $config = $this->createMockedConfiguration();\n $config->expects($this->atLeastOnce()) // Changed from once() to atLeastOnce()\n ->method('getId')\n ->willReturn(1);\n\n $salesforceUser = [\n 'Email' => 'test@example.com',\n 'UserPreferencesLightningExperiencePreferred' => true,\n 'CallCenterId' => '123',\n 'Id' => '456',\n 'ProfileId' => '789',\n 'CustomField' => 'CustomValue',\n ];\n\n $customRules = [\n ['field' => 'CustomField', 'value' => 'CustomValue'],\n ];\n\n $this->mockQueryBuilderAndHandler($userToSearch, [$salesforceUser]);\n\n $user = $this->createMock(User::class);\n $user->expects($this->once())\n ->method('getId')\n ->willReturn(123);\n\n $this->mockTeamRepository($team, $salesforceUser, $user, true, $customRules);\n\n $this->mockProfileRepository($user);\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n $profile = $serviceMock->syncProfiles($userToSearch);\n\n $this->assertNull($profile);\n }\n\n public function testSyncProfilesWithCustomValidationFailing(): void\n {\n $userToSearch = null;\n\n $team = $this->createMock(Team::class);\n $config = $this->createMockedConfiguration();\n\n $salesforceUser = [\n 'Email' => 'test@example.com',\n 'UserPreferencesLightningExperiencePreferred' => true,\n 'CallCenterId' => '123',\n 'Id' => '456',\n 'ProfileId' => '789',\n 'CustomField' => 'WrongValue',\n ];\n\n $customRules = [\n ['field' => 'CustomField', 'value' => 'CustomValue'],\n ];\n\n $this->mockQueryBuilderAndHandler($userToSearch, [$salesforceUser]);\n\n $teamRepository = $this->getMockForAbstractClass(TeamRepository::class, [], '', false, true, true, ['findActiveTeamMemberByEmail', 'getTeamSetting']);\n\n $teamSettings = $this->createMock(TeamSettings::class);\n $teamSettings->method('getValueType')\n ->willReturn('array');\n\n $teamSettings->method('getValue')\n ->willReturn(json_encode($customRules));\n\n $teamRepository->expects($this->once())\n ->method('getTeamSetting')\n ->with($team, 'custom_profile_validation')\n ->willReturn($teamSettings);\n\n app()->bind(TeamRepository::class, function () use ($teamRepository) {\n return $teamRepository;\n });\n\n $profileRepository = $this->createMock(ProfileRepository::class);\n $profileRepository->expects($this->never())\n ->method('updateOrCreateProfile');\n\n $this->app->instance(ProfileRepository::class, $profileRepository);\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n $result = $serviceMock->syncProfiles($userToSearch);\n\n $this->assertNull($result);\n }\n\n public function testGetContactRolesFromCrm(): void\n {\n $contactRoles = [\n [\n 'Id' => '1',\n 'ContactId' => 'Contact1',\n 'OpportunityId' => 'Opportunity1',\n 'Opportunity' => ['OwnerId' => 'Owner1'],\n 'IsPrimary' => true,\n 'Role' => 'Decision Maker',\n ],\n ];\n\n $expectedResponse = [\n [\n 'id' => '1',\n 'contactId' => 'Contact1',\n 'opportunityId' => 'Opportunity1',\n 'ownerId' => 'Owner1',\n 'isPrimary' => true,\n 'role' => 'Decision Maker',\n ],\n ];\n\n $this->bindQueryIterator($contactRoles);\n\n $serviceMock = $this->getServiceMock();\n\n $data = $serviceMock->getContactRolesFromCrm(now()->subDay());\n\n $this->assertEquals($expectedResponse, $data);\n }\n\n public function testGetContactRolesFromCrmNoResult(): void\n {\n $this->bindQueryIterator([]);\n\n $serviceMock = $this->getServiceMock();\n\n $data = $serviceMock->getContactRolesFromCrm(now()->subDay());\n\n $this->assertEquals([], $data);\n }\n\n public function testSyncContactRoles(): void\n {\n $contactRoles = [\n [\n 'id' => '1',\n 'contactId' => 'Contact1',\n 'opportunityId' => 'Opportunity1',\n 'ownerId' => 'Owner1',\n 'isPrimary' => true,\n 'role' => 'Decision Maker',\n ],\n ];\n\n app()->bind(ContactRoleRepository::class, function () {\n $contactRoleRepository = $this->createMock(ContactRoleRepository::class);\n $contactRoleRepository->expects($this->once())\n ->method('saveContactRoles');\n\n return $contactRoleRepository;\n });\n\n $serviceMock = $this->getServiceMock([\n 'getContactRolesFromCrm',\n 'syncRemotelyDeletedContactRoles',\n 'syncContact',\n 'syncOpportunity',\n ]);\n\n $config = $this->createMock(Configuration::class);\n $hasMany = $this->createMock(HasManyExtended::class);\n $hasMany->expects($this->exactly(2))\n ->method('where')\n ->willReturn($hasMany);\n\n $hasMany->expects($this->exactly(2))\n ->method('first')\n ->willReturn(\n $this->createMock(Contact::class),\n $this->createMock(Opportunity::class)\n );\n\n $config->expects($this->once())\n ->method('contacts')\n ->willReturn($hasMany);\n $config->expects($this->once())\n ->method('opportunities')\n ->willReturn($hasMany);\n\n $serviceMock->config = $config;\n\n $serviceMock->expects($this->once())\n ->method('getContactRolesFromCrm')\n ->willReturn($contactRoles);\n\n $serviceMock->expects($this->once())\n ->method('syncRemotelyDeletedContactRoles');\n\n $data = $serviceMock->syncContactRoles(now()->subDay());\n\n $this->assertEquals(1, $data);\n }\n\n public function testSyncRemotelyDeletedContactRoles(): void\n {\n $contactRoles = [\n [\n 'id' => '1',\n 'crm_provider_id' => '1',\n ],\n ];\n\n app()->bind(QueryHandler::class, function () use ($contactRoles) {\n $queryResults = new QueryResults($contactRoles, 1, true, null);\n\n $handler = $this->createMock(QueryHandler::class);\n $handler->method('queryDeleted')\n ->willReturn($queryResults);\n\n return $handler;\n });\n\n app()->bind(ContactRoleRepository::class, function () {\n $contactRoleRepository = $this->createMock(ContactRoleRepository::class);\n $contactRoleRepository->expects($this->once())\n ->method('deleteContactRoles');\n\n return $contactRoleRepository;\n });\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $this->createMock(Team::class);\n\n $data = $this->invokePrivateMethod('syncRemotelyDeletedContactRoles', $serviceMock, []);\n\n $this->assertTrue($data);\n }\n\n private function bindQueryIterator(array $queryResult): void\n {\n /** @var Client $client */\n $client = $this->createMock(Client::class);\n $queryIterator = new QueryIterator(\n $client,\n new QueryResults($queryResult, 1, true, null)\n );\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('query')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n }\n\n public static function getOpportunitySortOrderDataProvider(): array\n {\n return [\n 'all open recently updated' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED, ['LastModifiedDate DESC', true]],\n 'all open recently created' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED, ['CreatedDate DESC', true]],\n 'all open oldest created' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED, ['CreatedDate ASC', true]],\n 'all recently updated' => [Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED, ['LastModifiedDate DESC', false]],\n 'default' => ['unknown', ['LastModifiedDate DESC', true]],\n ];\n }\n\n private function createMockedConfiguration(): Configuration\n {\n $config = $this->createMock(Configuration::class);\n $profilesRelation = $this->getMockBuilder(\\Illuminate\\Database\\Eloquent\\Relations\\HasMany::class)\n ->disableOriginalConstructor()\n ->onlyMethods(['get'])\n ->addMethods(['where', 'first'])\n ->getMock();\n $profilesRelation->method('where')->willReturnSelf();\n $profilesRelation->method('get')->willReturn(collect([]));\n $profilesRelation->method('first')->willReturn(null);\n $config->method('profiles')->willReturn($profilesRelation);\n\n return $config;\n }\n\n private function getServiceMock(array $onlyMethods = []): MockObject&Service\n {\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $this->createMock(Client::class),\n $this->createMock(PayloadBuilder::class),\n $this->createMock(Dispatcher::class),\n $this->createMock(CountriesMap::class),\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods($onlyMethods)\n ->getMock();\n\n $serviceMock->profile = $this->createMock(Profile::class);\n\n return $serviceMock;\n }\n\n private function mockQueryBuilderAndHandler($userToSearch, $salesforceUsers): void\n {\n app()->bind(QueryBuilder::class, function () use ($userToSearch) {\n $queryBuilder = $this->createMock(QueryBuilder::class);\n $queryBuilder->expects($this->once())\n ->method('buildGetUsersQuery')\n ->with($userToSearch)\n ->willReturn('SELECT * FROM Users');\n\n return $queryBuilder;\n });\n\n $queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults($salesforceUsers, count($salesforceUsers), true, null));\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('query')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n }\n\n private function mockTeamRepository(\n Team $team,\n array $salesforceUser,\n ?User $user = null,\n bool $userSearch = true,\n array $customRules = []\n ): void {\n $teamRepository = $this->getMockForAbstractClass(TeamRepository::class, [], '', false, true, true, ['findActiveTeamMemberByEmail', 'getTeamSetting']);\n\n if ($userSearch) {\n $teamRepository->expects($this->once())\n ->method('findActiveTeamMemberByEmail')\n ->with($team, $salesforceUser['Email'])\n ->willReturn($user);\n }\n\n $teamSettings = $this->createMock(TeamSettings::class);\n $teamSettings->method('getValueType')\n ->willReturn('array');\n\n $teamSettings->method('getValue')\n ->willReturn(json_encode($customRules));\n\n $teamRepository->expects($this->once())\n ->method('getTeamSetting')\n ->with($team, 'custom_profile_validation')\n ->willReturn($teamSettings);\n\n app()->bind(TeamRepository::class, function () use ($teamRepository) {\n return $teamRepository;\n });\n }\n\n private function mockProfileRepository(User $user): void\n {\n $profileRepository = $this->createMock(ProfileRepository::class);\n $profileRepository->expects($this->once())\n ->method('updateOrCreateProfile')\n ->with(\n $user,\n [\n 'crm_configuration_id' => 1,\n 'crm_provider_id' => '456',\n ],\n [\n 'user_id' => 123,\n 'edition' => Profile::EDITION_LIGHTNING,\n 'has_external_cti' => true,\n 'crm_profile_id' => '789',\n ]\n )\n ->willReturn(new Profile());\n\n $this->app->instance(ProfileRepository::class, $profileRepository);\n }\n\n public function testBuildEnhancedNoteDecodesHtmlEntities(): void\n {\n $service = $this->getServiceMock(['createRecord']);\n\n $profile = new Profile();\n $profile->setAttribute('crm_provider_id', 'owner-123');\n $service->profile = $profile;\n\n $service->expects($this->exactly(2))\n ->method('createRecord')\n ->willReturnOnConsecutiveCalls('note-id-123', 'link-id-456');\n\n $bodyWithEntities = 'Welch's current challenges and Facebook's Club';\n\n $result = $this->invokePrivateMethod('buildEnhancedNote', $service, [\n 'Test Title',\n $bodyWithEntities,\n 'object-id-789',\n ]);\n\n $this->assertEquals('note-id-123', $result);\n }\n\n public function testBuildEnhancedNoteSanitizesWithoutQuotes(): void\n {\n $service = $this->getServiceMock(['createRecord']);\n\n $profile = new Profile();\n $profile->setAttribute('crm_provider_id', 'owner-456');\n $service->profile = $profile;\n\n $service->expects($this->exactly(2))\n ->method('createRecord')\n ->willReturnCallback(function ($type, $data) {\n if ($type === 'ContentNote') {\n $decoded = base64_decode($data['Content']);\n $this->assertStringContainsString(\"Welch's\", $decoded);\n $this->assertStringNotContainsString(''', $decoded);\n $this->assertStringNotContainsString('&#039;', $decoded);\n $this->assertStringContainsString('<script>', $decoded);\n\n return 'note-id-456';\n }\n\n return 'link-id-789';\n });\n\n $bodyWithMixedContent = \"Welch's and <script>alert('xss')</script>\";\n\n $result = $this->invokePrivateMethod('buildEnhancedNote', $service, [\n 'Test Title',\n $bodyWithMixedContent,\n 'object-id-123',\n ]);\n\n $this->assertEquals('note-id-456', $result);\n }\n\n public function testBuildEnhancedNoteConvertsLineBreaks(): void\n {\n $service = $this->getServiceMock(['createRecord']);\n\n $profile = new Profile();\n $profile->setAttribute('crm_provider_id', 'owner-789');\n $service->profile = $profile;\n\n $service->expects($this->exactly(2))\n ->method('createRecord')\n ->willReturnCallback(function ($type, $data) {\n if ($type === 'ContentNote') {\n $decoded = base64_decode($data['Content']);\n $this->assertStringContainsString('<br>', $decoded);\n $this->assertStringNotContainsString('<br />', $decoded);\n\n return 'note-id-789';\n }\n\n return 'link-id-012';\n });\n\n $bodyWithLineBreaks = \"Line 1\\nLine 2\\nLine 3\";\n\n $result = $this->invokePrivateMethod('buildEnhancedNote', $service, [\n 'Test Title',\n $bodyWithLineBreaks,\n 'object-id-456',\n ]);\n\n $this->assertEquals('note-id-789', $result);\n }\n\n public function testBuildEnhancedNoteHandlesComplexScenario(): void\n {\n $service = $this->getServiceMock(['createRecord']);\n\n $profile = new Profile();\n $profile->setAttribute('crm_provider_id', 'owner-complex');\n $service->profile = $profile;\n\n $service->expects($this->exactly(2))\n ->method('createRecord')\n ->willReturnCallback(function ($type, $data) {\n if ($type === 'ContentNote') {\n $decoded = base64_decode($data['Content']);\n\n $this->assertStringContainsString(\"Welch's\", $decoded);\n $this->assertStringContainsString(\"Facebook's Club\", $decoded);\n $this->assertStringContainsString(\"Arctics'\", $decoded);\n $this->assertStringNotContainsString(''', $decoded);\n $this->assertStringNotContainsString('&#039;', $decoded);\n $this->assertStringContainsString('<br>', $decoded);\n $this->assertStringContainsString('<', $decoded);\n $this->assertStringContainsString('>', $decoded);\n\n return 'note-complex';\n }\n\n return 'link-complex';\n });\n\n $complexBody = \"Summary:\\n---------\\nThe call focused on understanding Welch's current challenges and exploring how Arctics' Revenue Growth Management solutions could support their strategic goals.\\n\\n• John SMith discussed his role as a category advisor for Google and Facebook's Club, emphasizing the importance of market research and advising on product assortment.\\n• Madona introduced Arctics' Virtual Shoppers AI, which simulates consumer <behavior> to optimize pricing and promotional strategies.\";\n\n $result = $this->invokePrivateMethod('buildEnhancedNote', $service, [\n 'Jiminny Transcription Summary',\n $complexBody,\n 'task-id-001',\n ]);\n\n $this->assertEquals('note-complex', $result);\n }\n\n public function testSyncRemotelyDeletedObjectsWithErrorHandlingSuccess(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['syncRemotelyDeletedObjects'])\n ->getMock();\n\n // Mock team\n $team = $this->createMock(Team::class);\n $team->method('getUuid')->willReturn('team-uuid-123');\n $serviceMock->team = $team;\n\n // Expect syncRemotelyDeletedObjects to be called once and succeed\n $serviceMock->expects($this->once())\n ->method('syncRemotelyDeletedObjects')\n ->with(\\Jiminny\\Enums\\CrmObject::ACCOUNT);\n\n // Call the protected method using reflection\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');\n $method->setAccessible(true);\n\n // Should not throw any exceptions\n $method->invoke($serviceMock, \\Jiminny\\Enums\\CrmObject::ACCOUNT);\n\n $this->assertTrue(true); // Test completed successfully\n }\n\n public function testSyncRemotelyDeletedObjectsWithErrorHandlingFailure(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['syncRemotelyDeletedObjects'])\n ->getMock();\n\n // Mock team\n $team = $this->createMock(Team::class);\n $team->method('getUuid')->willReturn('team-uuid-456');\n $serviceMock->team = $team;\n\n // Mock logger to verify warning is logged\n $logger = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n\n // Use reflection to set the protected logger property\n $reflection = new \\ReflectionClass($serviceMock);\n $loggerProperty = $reflection->getProperty('logger');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($serviceMock, $logger);\n\n $exception = new \\Exception('Sync failed due to API error');\n\n // Expect syncRemotelyDeletedObjects to throw an exception\n $serviceMock->expects($this->once())\n ->method('syncRemotelyDeletedObjects')\n ->with(\\Jiminny\\Enums\\CrmObject::CONTACT)\n ->willThrowException($exception);\n\n // Expect warning to be logged with correct message and parameters\n $logger->expects($this->once())\n ->method('warning')\n ->with(\n '[Salesforce] Remotely deleted objects sync failed',\n [\n 'objectType' => 'contact',\n 'teamId' => 'team-uuid-456',\n 'reason' => 'Sync failed due to API error',\n ]\n );\n\n // Call the protected method using reflection\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');\n $method->setAccessible(true);\n\n // Should not re-throw the exception, just log it\n $method->invoke($serviceMock, \\Jiminny\\Enums\\CrmObject::CONTACT);\n\n $this->assertTrue(true); // Test completed successfully\n }\n\n public function testSyncRemotelyDeletedObjectsWithErrorHandlingWithLogParams(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['syncRemotelyDeletedObjects'])\n ->getMock();\n\n // Mock team\n $team = $this->createMock(Team::class);\n $team->method('getUuid')->willReturn('team-uuid-789');\n $serviceMock->team = $team;\n\n // Mock logger to verify warning is logged\n $logger = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n\n // Use reflection to set the protected logger property\n $loggerReflection = new \\ReflectionClass($serviceMock);\n $loggerProperty = $loggerReflection->getProperty('logger');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($serviceMock, $logger);\n\n $exception = new \\Exception('Network timeout');\n\n // Expect syncRemotelyDeletedObjects to throw an exception\n $serviceMock->expects($this->once())\n ->method('syncRemotelyDeletedObjects')\n ->with(\\Jiminny\\Enums\\CrmObject::OPPORTUNITY)\n ->willThrowException($exception);\n\n // Additional log parameters\n $logParams = [\n 'syncType' => 'full',\n 'batchSize' => 100,\n ];\n\n // Expect warning to be logged with merged parameters\n $logger->expects($this->once())\n ->method('warning')\n ->with(\n '[Salesforce] Remotely deleted objects sync failed',\n [\n 'objectType' => 'opportunity',\n 'teamId' => 'team-uuid-789',\n 'reason' => 'Network timeout',\n 'syncType' => 'full',\n 'batchSize' => 100,\n ]\n );\n\n // Call the protected method using reflection\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');\n $method->setAccessible(true);\n\n // Should not re-throw the exception, just log it\n $method->invoke($serviceMock, \\Jiminny\\Enums\\CrmObject::OPPORTUNITY, $logParams);\n\n $this->assertTrue(true); // Test completed successfully\n }\n\n /**\n * @dataProvider crmObjectProvider\n */\n public function testSyncRemotelyDeletedObjectsWithErrorHandlingDifferentCrmObjects(\\Jiminny\\Enums\\CrmObject $crmObject): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['syncRemotelyDeletedObjects'])\n ->getMock();\n\n // Mock team\n $team = $this->createMock(Team::class);\n $team->method('getUuid')->willReturn('team-uuid-test');\n $serviceMock->team = $team;\n\n // Mock logger to verify warning is logged\n $logger = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n\n // Use reflection to set the protected logger property\n $loggerReflectionClass = new \\ReflectionClass($serviceMock);\n $loggerProperty = $loggerReflectionClass->getProperty('logger');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($serviceMock, $logger);\n\n $exception = new \\Exception('Test error');\n\n // Expect syncRemotelyDeletedObjects to throw an exception\n $serviceMock->expects($this->once())\n ->method('syncRemotelyDeletedObjects')\n ->with($crmObject)\n ->willThrowException($exception);\n\n // Expect warning to be logged with correct entity type\n $logger->expects($this->once())\n ->method('warning')\n ->with(\n '[Salesforce] Remotely deleted objects sync failed',\n [\n 'objectType' => $crmObject->value,\n 'teamId' => 'team-uuid-test',\n 'reason' => 'Test error',\n ]\n );\n\n // Call the protected method using reflection\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');\n $method->setAccessible(true);\n\n $method->invoke($serviceMock, $crmObject);\n\n $this->assertTrue(true); // Test completed successfully\n }\n\n public function testHandleObjectDeletionWithDeletedEntity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['deleteCrmObject'])\n ->getMock();\n\n $entity = $this->createMock(\\Jiminny\\Models\\Account::class);\n $crmData = ['IsDeleted' => true];\n\n $serviceMock->expects($this->once())\n ->method('deleteCrmObject')\n ->with($entity);\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('handleObjectDeletion');\n $method->setAccessible(true);\n\n $method->invoke($serviceMock, $entity, $crmData);\n }\n\n public function testHandleObjectDeletionWithNonDeletedEntity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['deleteCrmObject'])\n ->getMock();\n\n $entity = $this->createMock(\\Jiminny\\Models\\Contact::class);\n $crmData = ['IsDeleted' => false];\n\n $serviceMock->expects($this->never())\n ->method('deleteCrmObject');\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('handleObjectDeletion');\n $method->setAccessible(true);\n\n $method->invoke($serviceMock, $entity, $crmData);\n }\n\n public function testDeleteCrmObjectWithValidEntity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['dispatchDeleteCrmObjectJob'])\n ->getMock();\n\n $entity = $this->createMock(\\Jiminny\\Models\\Lead::class);\n $entity->expects($this->once())->method('delete');\n\n $serviceMock->expects($this->once())\n ->method('dispatchDeleteCrmObjectJob')\n ->with($entity);\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('deleteCrmObject');\n $method->setAccessible(true);\n\n $method->invoke($serviceMock, $entity);\n }\n\n public function testDeleteCrmObjectWithNullEntity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['dispatchDeleteCrmObjectJob'])\n ->getMock();\n\n $serviceMock->expects($this->never())\n ->method('dispatchDeleteCrmObjectJob');\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('deleteCrmObject');\n $method->setAccessible(true);\n\n $method->invoke($serviceMock, null);\n }\n\n public function testDispatchDeleteCrmObjectJobWithNullEntity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($service);\n $method = $reflection->getMethod('dispatchDeleteCrmObjectJob');\n $method->setAccessible(true);\n\n // Should return early without dispatching - no exception expected\n $method->invoke($service, null);\n\n $this->assertTrue(true); // Test completed successfully\n }\n\n public function testDispatchDeleteCrmObjectJobWithUnsupportedEntity(): void\n {\n $this->expectException(\\TypeError::class);\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n $unsupportedEntity = $this->createMock(\\stdClass::class);\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($service);\n $method = $reflection->getMethod('dispatchDeleteCrmObjectJob');\n\n // This will throw TypeError due to union type constraint\n $method->invoke($service, $unsupportedEntity);\n }\n\n public function testHandleEntityDeletionByProviderIdMethodExists(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n // Test that the method exists and is accessible via reflection\n $reflection = new \\ReflectionClass($service);\n $method = $reflection->getMethod('handleEntityDeletionByProviderId');\n $method->setAccessible(true);\n\n // Verify method exists and has correct parameters\n $this->assertTrue($method->isProtected());\n $this->assertEquals(2, $method->getNumberOfParameters());\n\n $parameters = $method->getParameters();\n $this->assertEquals('targetEntity', $parameters[0]->getName());\n $this->assertEquals('crmData', $parameters[1]->getName());\n }\n\n public function testSyncRemotelyDeletedObjectsWithNoResults(): void\n {\n // Create a real service instance to avoid mock property issues\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n // Mock queryHandler to throw NoResultsException\n $queryHandler = $this->createMock(QueryHandler::class);\n $queryHandler->expects($this->once())\n ->method('queryDeleted')\n ->with('Opportunity')\n ->willThrowException(new NoResultsException('No results'));\n\n // Set the queryHandler using reflection on the real service\n $reflection = new \\ReflectionClass($service);\n $queryHandlerProperty = $reflection->getProperty('queryHandler');\n $queryHandlerProperty->setAccessible(true);\n $queryHandlerProperty->setValue($service, $queryHandler);\n\n $result = self::invokePrivateMethod('syncRemotelyDeletedObjects', $service, [CrmObject::OPPORTUNITY]);\n\n $this->assertFalse($result);\n }\n\n public function testSyncRemotelyDeletedObjectsWithEmptyResults(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n // Mock queryHandler to return empty results\n $queryResult = $this->createMock(QueryResults::class);\n $queryResult->method('getResults')->willReturn([]);\n\n $queryHandler = $this->createMock(QueryHandler::class);\n $queryHandler->expects($this->once())\n ->method('queryDeleted')\n ->with('Opportunity')\n ->willReturn($queryResult);\n\n // Set the queryHandler using reflection on the real service\n $reflection = new \\ReflectionClass($service);\n $queryHandlerProperty = $reflection->getProperty('queryHandler');\n $queryHandlerProperty->setAccessible(true);\n $queryHandlerProperty->setValue($service, $queryHandler);\n\n $result = self::invokePrivateMethod('syncRemotelyDeletedObjects', $service, [CrmObject::OPPORTUNITY]);\n\n $this->assertFalse($result);\n }\n\n public function testSyncRemotelyDeletedObjectsWithUnsupportedCrmObject(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n // Mock queryHandler to return some deleted objects so we reach the match statement\n $deletedObjects = [\n ['id' => 'task1'],\n ['id' => 'task2'],\n ];\n $queryResult = $this->createMock(QueryResults::class);\n $queryResult->method('getResults')->willReturn($deletedObjects);\n\n $queryHandler = $this->createMock(QueryHandler::class);\n $queryHandler->expects($this->once())\n ->method('queryDeleted')\n ->with('Task') // ucfirst('task') = 'Task'\n ->willReturn($queryResult);\n\n self::setPrivateProperty($service, 'queryHandler', $queryHandler);\n\n $this->expectException(InvalidArgumentException::class);\n $this->expectExceptionMessage('Unsupported CrmObject: task');\n\n self::invokePrivateMethod('syncRemotelyDeletedObjects', $service, [CrmObject::TASK]);\n }\n\n public static function crmObjectProvider(): array\n {\n return [\n 'Account' => [CrmObject::ACCOUNT],\n 'Contact' => [CrmObject::CONTACT],\n 'Lead' => [CrmObject::LEAD],\n 'Opportunity' => [CrmObject::OPPORTUNITY],\n ];\n }\n\n public function testVerifyTaskExistsReturnsTrue(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-123', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-123');\n $activity->method('getId')->willReturn(456);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-123', ['Id', 'IsDeleted'])\n ->willReturn(['Id' => 'task-123', 'IsDeleted' => false]);\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertTrue($result);\n }\n\n public function testVerifyTaskExistsReturnsTrueForEvent(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:event-123', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('event-123');\n $activity->method('getId')->willReturn(456);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_EVENT);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Event', 'event-123', ['Id', 'IsDeleted'])\n ->willReturn(['Id' => 'event-123', 'IsDeleted' => false]);\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertTrue($result);\n }\n\n public function testVerifyTaskExistsReturnsFalseWhenDeleted(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-456', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-456');\n $activity->method('getId')->willReturn(789);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-456', ['Id', 'IsDeleted'])\n ->willReturn(['Id' => 'task-456', 'IsDeleted' => true]);\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertFalse($result);\n }\n\n public function testVerifyTaskExistsReturnsFalseWhenNotFound(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-999', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-999');\n $activity->method('getId')->willReturn(999);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-999', ['Id', 'IsDeleted'])\n ->willThrowException(new \\Jiminny\\Exceptions\\HttpNotFoundException('Task not found'));\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertFalse($result);\n }\n\n public function testVerifyTaskExistsReturnsFalseWhenNoPlaybook(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-no-playbook', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-no-playbook');\n $activity->method('getId')->willReturn(111);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn(null);\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertFalse($result);\n }\n\n public function testVerifyTaskExistsThrowsExceptionForTransientErrors(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-error', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-error');\n $activity->method('getId')->willReturn(888);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-error', ['Id', 'IsDeleted'])\n ->willThrowException(new \\RuntimeException('Network timeout'));\n\n $this->expectException(\\RuntimeException::class);\n $this->expectExceptionMessage('Network timeout');\n\n $service->verifyTaskExists($activity);\n }\n\n public function testVerifyTaskExistsCachesResults(): void\n {\n $cachedValue = null;\n Cache::shouldReceive('remember')\n ->twice()\n ->with('crm_task_exists:123:task-cached', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(function ($key, $ttl, $callback) use (&$cachedValue) {\n if ($cachedValue === null) {\n $cachedValue = $callback();\n }\n\n return $cachedValue;\n });\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-cached');\n $activity->method('getId')->willReturn(555);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-cached', ['Id', 'IsDeleted'])\n ->willReturn(['Id' => 'task-cached', 'IsDeleted' => false]);\n\n $result1 = $service->verifyTaskExists($activity);\n $result2 = $service->verifyTaskExists($activity);\n\n $this->assertTrue($result1);\n $this->assertTrue($result2);\n }\n\n public function testVerifyTaskExistsReturnsFalseForHttpBadRequestException(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-400', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-400');\n $activity->method('getId')->willReturn(400);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-400', ['Id', 'IsDeleted'])\n ->willThrowException(new \\Jiminny\\Exceptions\\HttpBadRequestException('Bad request'));\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertFalse($result);\n }\n\n public function testImportOpportunitySkipsWhenNoProfileAndNoAccount(): void\n {\n $crmData = [\n 'Id' => 'SF-NO-USER-1',\n 'Name' => 'Test Opportunity',\n 'OwnerId' => 'owner-no-profile',\n // No AccountId\n ];\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(\\Illuminate\\Events\\Dispatcher::class); // ← ADD THIS\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n\n $service = new Service(\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService\n );\n\n $config = $this->createMock(Configuration::class);\n\n // Mock profiles relation returning null (no profile found)\n $profilesRelation = \\Mockery::mock(\\Illuminate\\Database\\Eloquent\\Relations\\HasMany::class);\n $profilesRelation->shouldReceive('where')->with('crm_provider_id', 'owner-no-profile')->andReturnSelf();\n $profilesRelation->shouldReceive('first')->andReturn(null);\n\n $config->expects($this->once())\n ->method('profiles')\n ->willReturn($profilesRelation);\n\n $team = $this->createMock(Team::class);\n $team->method('getId')->willReturn(1);\n\n $logger = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n $logger->expects($this->once())\n ->method('error')\n ->with(\n '[Salesforce] | Skip import, no user_id found',\n ['id' => 'SF-NO-USER-1']\n );\n\n $reflection = new \\ReflectionClass($service);\n\n $configProperty = $reflection->getProperty('config');\n $configProperty->setAccessible(true);\n $configProperty->setValue($service, $config);\n\n $teamProperty = $reflection->getProperty('team');\n $teamProperty->setAccessible(true);\n $teamProperty->setValue($service, $team);\n\n $loggerProperty = $reflection->getProperty('logger');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($service, $logger);\n\n // Initialize profile property to avoid \"must not be accessed before initialization\" error\n $profileProperty = $reflection->getProperty('profile');\n $profileProperty->setAccessible(true);\n $profileProperty->setValue($service, null);\n\n $result = self::invokePrivateMethod('importOpportunity', $service, [$crmData]);\n\n $this->assertNull($result);\n }\n\n public function testImportOpportunitySkipsWhenProfileNotFound(): void\n {\n $crmData = [\n 'Id' => 'SF-NO-USER-2',\n 'Name' => 'Test Opportunity',\n 'OwnerId' => 'owner-not-found',\n // No AccountId - avoid complex account processing\n ];\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $eventDispatcher = $this->createMock(\\Illuminate\\Events\\Dispatcher::class); // ← ADD THIS\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n\n\n $service = new Service(\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService\n );\n\n $config = $this->createMock(Configuration::class);\n\n // Mock profiles relation returning null (no profile found)\n $profilesRelation = \\Mockery::mock(\\Illuminate\\Database\\Eloquent\\Relations\\HasMany::class);\n $profilesRelation->shouldReceive('where')->with('crm_provider_id', 'owner-not-found')->andReturnSelf();\n $profilesRelation->shouldReceive('first')->andReturn(null);\n\n $config->expects($this->once())\n ->method('profiles')\n ->willReturn($profilesRelation);\n\n $team = $this->createMock(Team::class);\n $team->method('getId')->willReturn(1);\n\n $logger = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n $logger->expects($this->once())\n ->method('error')\n ->with(\n '[Salesforce] | Skip import, no user_id found',\n ['id' => 'SF-NO-USER-2']\n );\n\n $reflection = new \\ReflectionClass($service);\n\n $configProperty = $reflection->getProperty('config');\n $configProperty->setAccessible(true);\n $configProperty->setValue($service, $config);\n\n $teamProperty = $reflection->getProperty('team');\n $teamProperty->setAccessible(true);\n $teamProperty->setValue($service, $team);\n\n $loggerProperty = $reflection->getProperty('logger');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($service, $logger);\n\n // Initialize profile property\n $profileProperty = $reflection->getProperty('profile');\n $profileProperty->setAccessible(true);\n $profileProperty->setValue($service, null);\n\n $result = self::invokePrivateMethod('importOpportunity', $service, [$crmData]);\n\n $this->assertNull($result);\n }\n}\n\nclass HasManyExtended extends HasMany\n{\n public function where()\n {\n }\n\n public function first()\n {\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"A","depth":4,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":5,"bounds":{"left":0.27027926,"top":1.0,"width":0.0039893617,"height":0.0},"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Select All","depth":4,"bounds":{"left":0.42652926,"top":0.14365523,"width":0.09009308,"height":0.0207502},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"320","depth":6,"bounds":{"left":0.5169548,"top":0.16520351,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"320","depth":7,"bounds":{"left":0.5169548,"top":0.16520351,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"5772","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"5772","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"46ea38f7-c940-395d-9c79-0329d30e9ab7","depth":6,"bounds":{"left":0.5169548,"top":0.1867518,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"46ea38f7-c940-395d-9c79-0329d30e9ab7","depth":7,"bounds":{"left":0.5169548,"top":0.1867518,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"2024-11-26 09:21:15","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"2024-11-26 09:21:15","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"401","depth":6,"bounds":{"left":0.5169548,"top":0.20830008,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"401","depth":7,"bounds":{"left":0.5169548,"top":0.20830008,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"5772","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"5772","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"<null>","depth":6,"bounds":{"left":0.5169548,"top":0.22984837,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"<null>","depth":7,"bounds":{"left":0.5169548,"top":0.22984837,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"5772","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"5772","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"salesforce","depth":6,"bounds":{"left":0.5169548,"top":0.25139666,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"salesforce","depth":7,"bounds":{"left":0.5169548,"top":0.25139666,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"2024-11-26 09:21:15","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"2024-11-26 09:21:15","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"enterprise","depth":6,"bounds":{"left":0.5169548,"top":0.27294493,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"enterprise","depth":7,"bounds":{"left":0.5169548,"top":0.27294493,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"2024-11-26 09:21:15","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"2024-11-26 09:21:15","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"SWE38","depth":6,"bounds":{"left":0.5169548,"top":0.29449323,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"SWE38","depth":7,"bounds":{"left":0.5169548,"top":0.29449323,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"2024-11-26 09:21:15","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"2024-11-26 09:21:15","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"0","depth":6,"bounds":{"left":0.5169548,"top":0.3160415,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"0","depth":7,"bounds":{"left":0.5169548,"top":0.3160415,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"5772","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"5772","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"50.0","depth":6,"bounds":{"left":0.5169548,"top":0.33758977,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"50.0","depth":7,"bounds":{"left":0.5169548,"top":0.33758977,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"2024-11-26 09:21:15","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"2024-11-26 09:21:15","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"01801","depth":6,"bounds":{"left":0.5169548,"top":0.35913807,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"01801","depth":7,"bounds":{"left":0.5169548,"top":0.35913807,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"2024-11-26 09:21:15","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"2024-11-26 09:21:15","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"1","depth":6,"bounds":{"left":0.5169548,"top":0.38068634,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"1","depth":7,"bounds":{"left":0.5169548,"top":0.38068634,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"5772","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"5772","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"1","depth":6,"bounds":{"left":0.5169548,"top":0.40223464,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"1","depth":7,"bounds":{"left":0.5169548,"top":0.40223464,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"5772","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"5772","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"1","depth":6,"bounds":{"left":0.5169548,"top":0.4237829,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"1","depth":7,"bounds":{"left":0.5169548,"top":0.4237829,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"5772","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"5772","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"00D20000000MdbgEAC","depth":6,"bounds":{"left":0.5169548,"top":0.44533122,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"00D20000000MdbgEAC","depth":7,"bounds":{"left":0.5169548,"top":0.44533122,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"2024-11-26 09:21:15","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"2024-11-26 09:21:15","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"https://bipsolutions.my.salesforce.com","depth":6,"bounds":{"left":0.5169548,"top":0.4668795,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"https://bipsolutions.my.salesforce.com","depth":7,"bounds":{"left":0.5169548,"top":0.4668795,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"2024-11-26 09:21:15","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"2024-11-26 09:21:15","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"2026-03-19 08:19:24","depth":6,"bounds":{"left":0.5169548,"top":0.4884278,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"2026-03-19 08:19:24","depth":7,"bounds":{"left":0.5169548,"top":0.4884278,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"2024-11-26 09:21:15","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"2024-11-26 09:21:15","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"2026-05-22 09:48:21","depth":6,"bounds":{"left":0.5169548,"top":0.509976,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"2026-05-22 09:48:21","depth":7,"bounds":{"left":0.5169548,"top":0.509976,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"2024-11-26 09:21:15","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"2024-11-26 09:21:15","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"2026-05-22 09:48:31","depth":6,"bounds":{"left":0.5169548,"top":0.53152436,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"2026-05-22 09:48:31","depth":7,"bounds":{"left":0.5169548,"top":0.53152436,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"2024-11-26 09:21:15","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"2024-11-26 09:21:15","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
7732332105235147404
|
-6520523286572803295
|
idle
|
accessibility
|
NULL
|
Project: faVsco.js, menu
JY-20963-fix-import-on-de Project: faVsco.js, menu
JY-20963-fix-import-on-deleted-entity, menu
Start Listening for PHP Debug Connections
ServiceTest
Rerun 'PHPUnit: ServiceTest'
Debug 'ServiceTest'
Stop 'ServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Built-in Preview
Chrome
Firefox
Safari
Sync Changes
Hide This Notification
Code changed:
Hide
4
32
168
1
28
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Tests\Unit\Services\Crm\Salesforce;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Events\Dispatcher;
use Illuminate\Support\Facades\Cache;
use Jiminny\Component\Country\CountriesMap;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Enums\CrmObject;
use Jiminny\Exceptions\InvalidArgumentException;
use Jiminny\Exceptions\NoResultsException;
use Jiminny\Integrations\PlaybookResolver;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Playbook;
use Jiminny\Models\Team;
use Jiminny\Models\TeamSettings;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\ContactRoleRepository;
use Jiminny\Repositories\Crm\FieldDataRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\Salesforce\Client;
use Jiminny\Services\Crm\Salesforce\PayloadBuilder;
use Jiminny\Services\Crm\Salesforce\QueryBuilder;
use Jiminny\Services\Crm\Salesforce\QueryHandler;
use Jiminny\Services\Crm\Salesforce\QueryIterator;
use Jiminny\Services\Crm\Salesforce\QueryResults;
use Jiminny\Services\Crm\Salesforce\Service;
use PHPUnit\Framework\MockObject\MockObject;
use Tests\TestCase;
use Tests\Unit\Traits\TestPrivateMethod;
class ServiceTest extends TestCase
{
use TestPrivateMethod;
public function testFetchAndAssociateRelatedActivity(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$payloadBuilder->method('addCustomLogicFieldsPayload')
->willReturnCallback(function ($activity, $payload) {
return $payload;
});
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods(['fetchRelatedActivity', 'getPlaybook', 'getPlaybookCategory', 'updateRecord'])
->getMock();
$serviceMock->expects($this->once())
->method('fetchRelatedActivity')
->willReturn([
'Id' => 'testId',
'Type' => null,
'OwnerId' => 'testerUser',
'Description' => 'Test description',
]);
$user = $this->createMock(User::class);
$team = $this->createMock(Team::class);
$user->method('getAttribute')->with('team')->willReturn($team);
$playbook = $this->createMock(Playbook::class);
$playbook->method('getActivityField')->willReturn(null);
$playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_EVENT);
$serviceMock->expects($this->once())
->method('getPlaybook')
->with($user)
->willReturn($playbook);
$serviceMock->expects($this->never())
->method('getPlaybookCategory');
$serviceMock->expects($this->never())
->method('updateRecord');
$fieldDataRepository = $this->createMock(FieldDataRepository::class);
$fieldDataRepository->method('getActivityFieldData')->willReturn(collect([]));
app()->instance(FieldDataRepository::class, $fieldDataRepository);
$config = $this->createMock(Configuration::class);
$profilesRelation = $this->getMockBuilder(\Illuminate\Database\Eloquent\Relations\HasMany::class)
->disableOriginalConstructor()
->onlyMethods(['get'])
->addMethods(['where'])
->getMock();
$profilesRelation->method('where')->willReturnSelf();
$profilesRelation->method('get')->willReturn(collect([]));
$config->method('profiles')->willReturn($profilesRelation);
$serviceMock->config = $config;
$serviceMock->profile = null;
$actualStartTime = \Carbon\Carbon::now();
$activity = $this->getMockBuilder(Activity::class)
->disableOriginalConstructor()
->onlyMethods(['update', 'hasProspect'])
->getMock();
$activity->method('update')->willReturn(true);
$activity->method('hasProspect')->willReturn(true);
$activity->type = Activity::TYPE_CONFERENCE;
$activity->provider = Activity::PROVIDER_TWILIO;
$activity->lead_id = 1;
$activity->user_id = 0;
$activity->id_string = 'test-activity-id';
$activity->user = $user;
$activity->actual_start_time = $actualStartTime;
$activity->uuid = 'c53d8320-f556-4cee-a2f8-5f232f454ca4';
app()->bind(PlaybookResolver::class, function () use ($user) {
$playbook = $this->createMock(Playbook::class);
$playbookResolver = $this->createMock(PlaybookResolver::class);
$playbookResolver->expects($this->once())
->method('resolvePlaybookByUser')
->with($user)
->willReturn($playbook);
return $playbookResolver;
});
$data = $serviceMock->fetchAndAssociateRelatedActivity($activity);
$this->assertInstanceOf(Activity::class, $data);
$this->assertEquals(Activity::TYPE_CONFERENCE, $data->getType());
$this->assertEquals($actualStartTime->getTimestamp(), $data->getActualStartTime()->getTimestamp());
}
public function testFetchAndAssociateRelatedActivitySkipsForTaskBasedPlaybook(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods(['fetchRelatedActivity', 'getPlaybook'])
->getMock();
$user = $this->createMock(User::class);
$playbook = $this->createMock(Playbook::class);
$playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);
$playbook->method('getId')->willReturn(123);
$serviceMock->expects($this->once())
->method('getPlaybook')
->with($user)
->willReturn($playbook);
$serviceMock->expects($this->never())
->method('fetchRelatedActivity');
$activity = $this->getMockBuilder(Activity::class)
->disableOriginalConstructor()
->onlyMethods(['hasProspect', 'getUuid'])
->getMock();
$activity->method('hasProspect')->willReturn(true);
$activity->method('getUuid')->willReturn('c53d8320-f556-4cee-a2f8-5f232f454ca4');
$activity->type = Activity::TYPE_CONFERENCE;
$activity->actual_start_time = \Carbon\Carbon::now();
$activity->user = $user;
$result = $serviceMock->fetchAndAssociateRelatedActivity($activity);
$this->assertNull($result);
}
public function testFetchAndAssociateRelatedActivityReturnsNullForNonConference(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$serviceMock = new Service(
client: $client,
payloadBuilder: $payloadBuilder,
eventDispatcher: $eventDispatcher,
countriesMap: $countriesMap,
prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)
);
$activity = $this->getMockBuilder(Activity::class)
->disableOriginalConstructor()
->getMock();
$activity->type = Activity::TYPE_SOFTPHONE;
$result = $serviceMock->fetchAndAssociateRelatedActivity($activity);
$this->assertNull($result);
}
public function testFetchAndAssociateRelatedActivityReturnsNullWhenNoStartTime(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$serviceMock = new Service(
client: $client,
payloadBuilder: $payloadBuilder,
eventDispatcher: $eventDispatcher,
countriesMap: $countriesMap,
prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)
);
$activity = $this->getMockBuilder(Activity::class)
->disableOriginalConstructor()
->getMock();
$activity->type = Activity::TYPE_CONFERENCE;
$activity->actual_start_time = null;
$activity->scheduled_start_time = null;
$result = $serviceMock->fetchAndAssociateRelatedActivity($activity);
$this->assertNull($result);
}
public function testFetchAndAssociateRelatedActivityReturnsNullWhenNoProspect(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods(['getPlaybook'])
->getMock();
$serviceMock->expects($this->never())
->method('getPlaybook');
$activity = $this->getMockBuilder(Activity::class)
->disableOriginalConstructor()
->onlyMethods(['hasProspect', 'getUuid'])
->getMock();
$activity->method('hasProspect')->willReturn(false);
$activity->method('getUuid')->willReturn('c53d8320-f556-4cee-a2f8-5f232f454ca4');
$activity->type = Activity::TYPE_CONFERENCE;
$activity->actual_start_time = \Carbon\Carbon::now();
$result = $serviceMock->fetchAndAssociateRelatedActivity($activity);
$this->assertNull($result);
}
public function testMatchExactlyByEmail(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods([])
->getMock();
$profile = new Profile();
$profile->setAttribute('id', bin2hex(random_bytes(8)));
$serviceMock->profile = $profile;
$team = $this->createMock(Team::class);
$serviceMock->team = $team;
$data = $serviceMock->matchExactlyByEmail(bin2hex(random_bytes(8)) . '[EMAIL]');
$this->assertEquals(null, $data);
}
public function testMatchDomainFromEmail(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$queryIterator = $this->createMock(QueryIterator::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$config = $this->createMock(Configuration::class);
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('search')
->willReturn($queryIterator);
return $handler;
});
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods(['convertCrmData'])
->getMock();
$profile = new Profile();
$profile->account_fields = 'Field1, Field2, Field3';
$serviceMock->profile = $profile;
$serviceMock->expects($this->once())
->method('convertCrmData')
->willReturn(['test']);
$this->app->bind(QueryBuilder::class, function () {
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->expects($this->once())
->method('buildMatchByDomainQuery')
->with('[EMAIL]')
->willReturn('FIND {[EMAIL]} IN ALL FIELDS RETURNING Account(Id)');
return $queryBuilder;
});
$team = $this->createMock(Team::class);
$serviceMock->team = $team;
$serviceMock->config = $config;
$data = $serviceMock->matchByDomain('[EMAIL]');
$this->assertEquals(['test'], $data);
}
public function testBuildTaskSearchFields(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$service = new Service(
client: $client,
payloadBuilder: $payloadBuilder,
eventDispatcher: $eventDispatcher,
countriesMap: $countriesMap,
prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)
);
$fields = $service->buildTaskSearchFields();
$expectedFields = ['Id', 'WhoId', 'WhatId', 'AccountId'];
$this->assertEquals($expectedFields, $fields);
}
public function testMapCrmObjects(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$service = new Service(
client: $client,
payloadBuilder: $payloadBuilder,
eventDispatcher: $eventDispatcher,
countriesMap: $countriesMap,
prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class),
);
$sampleTask = [
'WhoId' => '003sampleWhoId',
'AccountId' => 'sampleAccountId',
'WhatId' => 'sampleWhatId',
];
$activityData = $service->mapCrmObjects($sampleTask);
$expectedActivityData = [
'contact' => '003sampleWhoId',
'account' => 'sampleAccountId',
'opportunity' => 'sampleWhatId',
];
$this->assertEquals($expectedActivityData, $activityData);
}
public function testGetInstalledAppVersion(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$queryIterator = $this->createMock(QueryIterator::class);
$queryIterator->expects($this->any())
->method('current')->willReturn([
'SubscriberPackageVersion' => [
'MajorVersion' => '1',
'MinorVersion' => '0',
'PatchVersion' => '1',
'BuildNumber' => '0',
],
]);
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('metadata')
->willReturn($queryIterator);
return $handler;
});
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods(array_diff(get_class_methods(Service::class), ['getInstalledAppVersion']))
->getMock();
$version = $serviceMock->getInstalledAppVersion();
$this->assertEquals('1010', $version);
}
public function testSyncProfiles(): void
{
$userToSearch = null;
$team = $this->createMock(Team::class);
$config = $this->createMockedConfiguration();
$config->expects($this->once())
->method('getId')
->willReturn(1);
$salesforceUser = [
'Email' => '[EMAIL]',
'UserPreferencesLightningExperiencePreferred' => true,
'CallCenterId' => '123',
'Id' => '456',
'ProfileId' => '789',
];
app()->bind(QueryBuilder::class, function () use ($userToSearch) {
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->expects($this->once())
->method('buildGetUsersQuery')
->with($userToSearch)
->willReturn('SELECT * FROM Users');
return $queryBuilder;
});
$queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('query')
->with('SELECT * FROM Users')
->willReturn($queryIterator);
return $handler;
});
$user = $this->createMock(User::class);
$user->expects($this->once())
->method('getId')
->willReturn(123);
$this->mockTeamRepository($team, $salesforceUser, $user);
$profileRepository = $this->createMock(ProfileRepository::class);
$profileRepository->expects($this->once())
->method('updateOrCreateProfile')
->with(
$user,
[
'crm_configuration_id' => 1,
'crm_provider_id' => '456',
],
[
'user_id' => 123,
'edition' => Profile::EDITION_LIGHTNING,
'has_external_cti' => true,
'crm_profile_id' => '789',
]
)
->willReturn(new Profile());
$this->app->instance(ProfileRepository::class, $profileRepository);
$serviceMock = $this->getServiceMock();
$serviceMock->team = $team;
$serviceMock->config = $config;
$result = $serviceMock->syncProfiles($userToSearch);
$this->assertNull($result);
}
public function testSyncProfilesEmailIsNull(): void
{
$userToSearch = $this->createMock(User::class);
$salesforceUser = [
'Email' => null,
];
app()->bind(QueryBuilder::class, function () {
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->expects($this->once())
->method('buildGetUsersQuery')
->with(null)
->willReturn('SELECT * FROM Users');
return $queryBuilder;
});
$queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->exactly(2))
->method('query')
->willReturn($queryIterator);
return $handler;
});
$team = $this->createMock(Team::class);
$user = $this->createMock(User::class);
$this->mockTeamRepository($team, $salesforceUser, $user, false);
$config = $this->createMock(Configuration::class);
$serviceMock = $this->getServiceMock();
$serviceMock->team = $team;
$serviceMock->config = $config;
$profile = $serviceMock->syncProfiles(null);
$this->assertNull($profile);
}
public function testSyncProfilesUserToSearchMatchesCurrentUser(): void
{
$userToSearch = $this->createMock(User::class);
$userToSearch->expects($this->once())
->method('getId')
->willReturn(123);
$team = $this->createMock(Team::class);
$config = $this->createMock(Configuration::class);
$config->expects($this->once())
->method('getId')
->willReturn(1);
$salesforceUser = [
'Email' => '[EMAIL]',
'UserPreferencesLightningExperiencePreferred' => true,
'CallCenterId' => '123',
'Id' => '456',
'ProfileId' => '789',
];
app()->bind(QueryBuilder::class, function () use ($userToSearch) {
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->expects($this->once())
->method('buildGetUsersQuery')
->with($userToSearch)
->willReturn('SELECT * FROM Users');
return $queryBuilder;
});
$queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('query')
->with('SELECT * FROM Users')
->willReturn($queryIterator);
return $handler;
});
$user = $this->createMock(User::class);
$user->expects($this->exactly(2))
->method('getId')
->willReturn(123);
$this->mockTeamRepository($team, $salesforceUser, $user);
$profileRepository = $this->createMock(ProfileRepository::class);
$profileRepository->expects($this->once())
->method('updateOrCreateProfile')
->with(
$user,
[
'crm_configuration_id' => 1,
'crm_provider_id' => '456',
],
[
'user_id' => 123,
'edition' => Profile::EDITION_LIGHTNING,
'has_external_cti' => true,
'crm_profile_id' => '789',
]
)
->willReturn(new Profile());
$this->app->instance(ProfileRepository::class, $profileRepository);
$serviceMock = $this->getServiceMock();
$serviceMock->team = $team;
$serviceMock->config = $config;
$profile = $serviceMock->syncProfiles($userToSearch);
$this->assertInstanceOf(Profile::class, $profile);
}
public function testSyncProfilesWithCustomValidation(): void
{
$userToSearch = null;
$team = $this->createMock(Team::class);
$config = $this->createMockedConfiguration();
$config->expects($this->atLeastOnce()) // Changed from once() to atLeastOnce()
->method('getId')
->willReturn(1);
$salesforceUser = [
'Email' => '[EMAIL]',
'UserPreferencesLightningExperiencePreferred' => true,
'CallCenterId' => '123',
'Id' => '456',
'ProfileId' => '789',
'CustomField' => 'CustomValue',
];
$customRules = [
['field' => 'CustomField', 'value' => 'CustomValue'],
];
$this->mockQueryBuilderAndHandler($userToSearch, [$salesforceUser]);
$user = $this->createMock(User::class);
$user->expects($this->once())
->method('getId')
->willReturn(123);
$this->mockTeamRepository($team, $salesforceUser, $user, true, $customRules);
$this->mockProfileRepository($user);
$serviceMock = $this->getServiceMock();
$serviceMock->team = $team;
$serviceMock->config = $config;
$profile = $serviceMock->syncProfiles($userToSearch);
$this->assertNull($profile);
}
public function testSyncProfilesWithCustomValidationFailing(): void
{
$userToSearch = null;
$team = $this->createMock(Team::class);
$config = $this->createMockedConfiguration();
$salesforceUser = [
'Email' => '[EMAIL]',
'UserPreferencesLightningExperiencePreferred' => true,
'CallCenterId' => '123',
'Id' => '456',
'ProfileId' => '789',
'CustomField' => 'WrongValue',
];
$customRules = [
['field' => 'CustomField', 'value' => 'CustomValue'],
];
$this->mockQueryBuilderAndHandler($userToSearch, [$salesforceUser]);
$teamRepository = $this->getMockForAbstractClass(TeamRepository::class, [], '', false, true, true, ['findActiveTeamMemberByEmail', 'getTeamSetting']);
$teamSettings = $this->createMock(TeamSettings::class);
$teamSettings->method('getValueType')
->willReturn('array');
$teamSettings->method('getValue')
->willReturn(json_encode($customRules));
$teamRepository->expects($this->once())
->method('getTeamSetting')
->with($team, 'custom_profile_validation')
->willReturn($teamSettings);
app()->bind(TeamRepository::class, function () use ($teamRepository) {
return $teamRepository;
});
$profileRepository = $this->createMock(ProfileRepository::class);
$profileRepository->expects($this->never())
->method('updateOrCreateProfile');
$this->app->instance(ProfileRepository::class, $profileRepository);
$serviceMock = $this->getServiceMock();
$serviceMock->team = $team;
$serviceMock->config = $config;
$result = $serviceMock->syncProfiles($userToSearch);
$this->assertNull($result);
}
public function testGetContactRolesFromCrm(): void
{
$contactRoles = [
[
'Id' => '1',
'ContactId' => 'Contact1',
'OpportunityId' => 'Opportunity1',
'Opportunity' => ['OwnerId' => 'Owner1'],
'IsPrimary' => true,
'Role' => 'Decision Maker',
],
];
$expectedResponse = [
[
'id' => '1',
'contactId' => 'Contact1',
'opportunityId' => 'Opportunity1',
'ownerId' => 'Owner1',
'isPrimary' => true,
'role' => 'Decision Maker',
],
];
$this->bindQueryIterator($contactRoles);
$serviceMock = $this->getServiceMock();
$data = $serviceMock->getContactRolesFromCrm(now()->subDay());
$this->assertEquals($expectedResponse, $data);
}
public function testGetContactRolesFromCrmNoResult(): void
{
$this->bindQueryIterator([]);
$serviceMock = $this->getServiceMock();
$data = $serviceMock->getContactRolesFromCrm(now()->subDay());
$this->assertEquals([], $data);
}
public function testSyncContactRoles(): void
{
$contactRoles = [
[
'id' => '1',
'contactId' => 'Contact1',
'opportunityId' => 'Opportunity1',
'ownerId' => 'Owner1',
'isPrimary' => true,
'role' => 'Decision Maker',
],
];
app()->bind(ContactRoleRepository::class, function () {
$contactRoleRepository = $this->createMock(ContactRoleRepository::class);
$contactRoleRepository->expects($this->once())
->method('saveContactRoles');
return $contactRoleRepository;
});
$serviceMock = $this->getServiceMock([
'getContactRolesFromCrm',
'syncRemotelyDeletedContactRoles',
'syncContact',
'syncOpportunity',
]);
$config = $this->createMock(Configuration::class);
$hasMany = $this->createMock(HasManyExtended::class);
$hasMany->expects($this->exactly(2))
->method('where')
->willReturn($hasMany);
$hasMany->expects($this->exactly(2))
->method('first')
->willReturn(
$this->createMock(Contact::class),
$this->createMock(Opportunity::class)
);
$config->expects($this->once())
->method('contacts')
->willReturn($hasMany);
$config->expects($this->once())
->method('opportunities')
->willReturn($hasMany);
$serviceMock->config = $config;
$serviceMock->expects($this->once())
->method('getContactRolesFromCrm')
->willReturn($contactRoles);
$serviceMock->expects($this->once())
->method('syncRemotelyDeletedContactRoles');
$data = $serviceMock->syncContactRoles(now()->subDay());
$this->assertEquals(1, $data);
}
public function testSyncRemotelyDeletedContactRoles(): void
{
$contactRoles = [
[
'id' => '1',
'crm_provider_id' => '1',
],
];
app()->bind(QueryHandler::class, function () use ($contactRoles) {
$queryResults = new QueryResults($contactRoles, 1, true, null);
$handler = $this->createMock(QueryHandler::class);
$handler->method('queryDeleted')
->willReturn($queryResults);
return $handler;
});
app()->bind(ContactRoleRepository::class, function () {
$contactRoleRepository = $this->createMock(ContactRoleRepository::class);
$contactRoleRepository->expects($this->once())
->method('deleteContactRoles');
return $contactRoleRepository;
});
$serviceMock = $this->getServiceMock();
$serviceMock->team = $this->createMock(Team::class);
$data = $this->invokePrivateMethod('syncRemotelyDeletedContactRoles', $serviceMock, []);
$this->assertTrue($data);
}
private function bindQueryIterator(array $queryResult): void
{
/** @var Client $client */
$client = $this->createMock(Client::class);
$queryIterator = new QueryIterator(
$client,
new QueryResults($queryResult, 1, true, null)
);
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('query')
->willReturn($queryIterator);
return $handler;
});
}
public static function getOpportunitySortOrderDataProvider(): array
{
return [
'all open recently updated' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED, ['LastModifiedDate DESC', true]],
'all open recently created' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED, ['CreatedDate DESC', true]],
'all open oldest created' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED, ['CreatedDate ASC', true]],
'all recently updated' => [Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED, ['LastModifiedDate DESC', false]],
'default' => ['unknown', ['LastModifiedDate DESC', true]],
];
}
private function createMockedConfiguration(): Configuration
{
$config = $this->createMock(Configuration::class);
$profilesRelation = $this->getMockBuilder(\Illuminate\Database\Eloquent\Relations\HasMany::class)
->disableOriginalConstructor()
->onlyMethods(['get'])
->addMethods(['where', 'first'])
->getMock();
$profilesRelation->method('where')->willReturnSelf();
$profilesRelation->method('get')->willReturn(collect([]));
$profilesRelation->method('first')->willReturn(null);
$config->method('profiles')->willReturn($profilesRelation);
return $config;
}
private function getServiceMock(array $onlyMethods = []): MockObject&Service
{
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$this->createMock(Client::class),
$this->createMock(PayloadBuilder::class),
$this->createMock(Dispatcher::class),
$this->createMock(CountriesMap::class),
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods($onlyMethods)
->getMock();
$serviceMock->profile = $this->createMock(Profile::class);
return $serviceMock;
}
private function mockQueryBuilderAndHandler($userToSearch, $salesforceUsers): void
{
app()->bind(QueryBuilder::class, function () use ($userToSearch) {
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->expects($this->once())
->method('buildGetUsersQuery')
->with($userToSearch)
->willReturn('SELECT * FROM Users');
return $queryBuilder;
});
$queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults($salesforceUsers, count($salesforceUsers), true, null));
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('query')
->willReturn($queryIterator);
return $handler;
});
}
private function mockTeamRepository(
Team $team,
array $salesforceUser,
?User $user = null,
bool $userSearch = true,
array $customRules = []
): void {
$teamRepository = $this->getMockForAbstractClass(TeamRepository::class, [], '', false, true, true, ['findActiveTeamMemberByEmail', 'getTeamSetting']);
if ($userSearch) {
$teamRepository->expects($this->once())
->method('findActiveTeamMemberByEmail')
->with($team, $salesforceUser['Email'])
->willReturn($user);
}
$teamSettings = $this->createMock(TeamSettings::class);
$teamSettings->method('getValueType')
->willReturn('array');
$teamSettings->method('getValue')
->willReturn(json_encode($customRules));
$teamRepository->expects($this->once())
->method('getTeamSetting')
->with($team, 'custom_profile_validation')
->willReturn($teamSettings);
app()->bind(TeamRepository::class, function () use ($teamRepository) {
return $teamRepository;
});
}
private function mockProfileRepository(User $user): void
{
$profileRepository = $this->createMock(ProfileRepository::class);
$profileRepository->expects($this->once())
->method('updateOrCreateProfile')
->with(
$user,
[
'crm_configuration_id' => 1,
'crm_provider_id' => '456',
],
[
'user_id' => 123,
'edition' => Profile::EDITION_LIGHTNING,
'has_external_cti' => true,
'crm_profile_id' => '789',
]
)
->willReturn(new Profile());
$this->app->instance(ProfileRepository::class, $profileRepository);
}
public function testBuildEnhancedNoteDecodesHtmlEntities(): void
{
$service = $this->getServiceMock(['createRecord']);
$profile = new Profile();
$profile->setAttribute('crm_provider_id', 'owner-123');
$service->profile = $profile;
$service->expects($this->exactly(2))
->method('createRecord')
->willReturnOnConsecutiveCalls('note-id-123', 'link-id-456');
$bodyWithEntities = 'Welch's current challenges and Facebook's Club';
$result = $this->invokePrivateMethod('buildEnhancedNote', $service, [
'Test Title',
$bodyWithEntities,
'object-id-789',
]);
$this->assertEquals('note-id-123', $result);
}
public function testBuildEnhancedNoteSanitizesWithoutQuotes(): void
{
$service = $this->getServiceMock(['createRecord']);
$profile = new Profile();
$profile->setAttribute('crm_provider_id', 'owner-456');
$service->profile = $profile;
$service->expects($this->exactly(2))
->method('createRecord')
->willReturnCallback(function ($type, $data) {
if ($type === 'ContentNote') {
$decoded = base64_decode($data['Content']);
$this->assertStringContainsString("Welch's", $decoded);
$this->assertStringNotContainsString(''', $decoded);
$this->assertStringNotContainsString('&#039;', $decoded);
$this->assertStringContainsString('<script>', $decoded);
return 'note-id-456';
}
return 'link-id-789';
});
$bodyWithMixedContent = "Welch's and <script>alert('xss')</script>";
$result = $this->invokePrivateMethod('buildEnhancedNote', $service, [
'Test Title',
$bodyWithMixedContent,
'object-id-123',
]);
$this->assertEquals('note-id-456', $result);
}
public function testBuildEnhancedNoteConvertsLineBreaks(): void
{
$service = $this->getServiceMock(['createRecord']);
$profile = new Profile();
$profile->setAttribute('crm_provider_id', 'owner-789');
$service->profile = $profile;
$service->expects($this->exactly(2))
->method('createRecord')
->willReturnCallback(function ($type, $data) {
if ($type === 'ContentNote') {
$decoded = base64_decode($data['Content']);
$this->assertStringContainsString('<br>', $decoded);
$this->assertStringNotContainsString('<br />', $decoded);
return 'note-id-789';
}
return 'link-id-012';
});
$bodyWithLineBreaks = "Line 1\nLine 2\nLine 3";
$result = $this->invokePrivateMethod('buildEnhancedNote', $service, [
'Test Title',
$bodyWithLineBreaks,
'object-id-456',
]);
$this->assertEquals('note-id-789', $result);
}
public function testBuildEnhancedNoteHandlesComplexScenario(): void
{
$service = $this->getServiceMock(['createRecord']);
$profile = new Profile();
$profile->setAttribute('crm_provider_id', 'owner-complex');
$service->profile = $profile;
$service->expects($this->exactly(2))
->method('createRecord')
->willReturnCallback(function ($type, $data) {
if ($type === 'ContentNote') {
$decoded = base64_decode($data['Content']);
$this->assertStringContainsString("Welch's", $decoded);
$this->assertStringContainsString("Facebook's Club", $decoded);
$this->assertStringContainsString("Arctics'", $decoded);
$this->assertStringNotContainsString(''', $decoded);
$this->assertStringNotContainsString('&#039;', $decoded);
$this->assertStringContainsString('<br>', $decoded);
$this->assertStringContainsString('<', $decoded);
$this->assertStringContainsString('>', $decoded);
return 'note-complex';
}
return 'link-complex';
});
$complexBody = "Summary:\n---------\nThe call focused on understanding Welch's current challenges and exploring how Arctics' Revenue Growth Management solutions could support their strategic goals.\n\n• John SMith discussed his role as a category advisor for Google and Facebook's Club, emphasizing the importance of market research and advising on product assortment.\n• Madona introduced Arctics' Virtual Shoppers AI, which simulates consumer <behavior> to optimize pricing and promotional strategies.";
$result = $this->invokePrivateMethod('buildEnhancedNote', $service, [
'Jiminny Transcription Summary',
$complexBody,
'task-id-001',
]);
$this->assertEquals('note-complex', $result);
}
public function testSyncRemotelyDeletedObjectsWithErrorHandlingSuccess(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$prospectPhotoPathService,
])
->onlyMethods(['syncRemotelyDeletedObjects'])
->getMock();
// Mock team
$team = $this->createMock(Team::class);
$team->method('getUuid')->willReturn('team-uuid-123');
$serviceMock->team = $team;
// Expect syncRemotelyDeletedObjects to be called once and succeed
$serviceMock->expects($this->once())
->method('syncRemotelyDeletedObjects')
->with(\Jiminny\Enums\CrmObject::ACCOUNT);
// Call the protected method using reflection
$reflection = new \ReflectionClass($serviceMock);
$method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');
$method->setAccessible(true);
// Should not throw any exceptions
$method->invoke($serviceMock, \Jiminny\Enums\CrmObject::ACCOUNT);
$this->assertTrue(true); // Test completed successfully
}
public function testSyncRemotelyDeletedObjectsWithErrorHandlingFailure(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$prospectPhotoPathService,
])
->onlyMethods(['syncRemotelyDeletedObjects'])
->getMock();
// Mock team
$team = $this->createMock(Team::class);
$team->method('getUuid')->willReturn('team-uuid-456');
$serviceMock->team = $team;
// Mock logger to verify warning is logged
$logger = $this->createMock(\Psr\Log\LoggerInterface::class);
// Use reflection to set the protected logger property
$reflection = new \ReflectionClass($serviceMock);
$loggerProperty = $reflection->getProperty('logger');
$loggerProperty->setAccessible(true);
$loggerProperty->setValue($serviceMock, $logger);
$exception = new \Exception('Sync failed due to API error');
// Expect syncRemotelyDeletedObjects to throw an exception
$serviceMock->expects($this->once())
->method('syncRemotelyDeletedObjects')
->with(\Jiminny\Enums\CrmObject::CONTACT)
->willThrowException($exception);
// Expect warning to be logged with correct message and parameters
$logger->expects($this->once())
->method('warning')
->with(
'[Salesforce] Remotely deleted objects sync failed',
[
'objectType' => 'contact',
'teamId' => 'team-uuid-456',
'reason' => 'Sync failed due to API error',
]
);
// Call the protected method using reflection
$reflection = new \ReflectionClass($serviceMock);
$method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');
$method->setAccessible(true);
// Should not re-throw the exception, just log it
$method->invoke($serviceMock, \Jiminny\Enums\CrmObject::CONTACT);
$this->assertTrue(true); // Test completed successfully
}
public function testSyncRemotelyDeletedObjectsWithErrorHandlingWithLogParams(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$prospectPhotoPathService,
])
->onlyMethods(['syncRemotelyDeletedObjects'])
->getMock();
// Mock team
$team = $this->createMock(Team::class);
$team->method('getUuid')->willReturn('team-uuid-789');
$serviceMock->team = $team;
// Mock logger to verify warning is logged
$logger = $this->createMock(\Psr\Log\LoggerInterface::class);
// Use reflection to set the protected logger property
$loggerReflection = new \ReflectionClass($serviceMock);
$loggerProperty = $loggerReflection->getProperty('logger');
$loggerProperty->setAccessible(true);
$loggerProperty->setValue($serviceMock, $logger);
$exception = new \Exception('Network timeout');
// Expect syncRemotelyDeletedObjects to throw an exception
$serviceMock->expects($this->once())
->method('syncRemotelyDeletedObjects')
->with(\Jiminny\Enums\CrmObject::OPPORTUNITY)
->willThrowException($exception);
// Additional log parameters
$logParams = [
'syncType' => 'full',
'batchSize' => 100,
];
// Expect warning to be logged with merged parameters
$logger->expects($this->once())
->method('warning')
->with(
'[Salesforce] Remotely deleted objects sync failed',
[
'objectType' => 'opportunity',
'teamId' => 'team-uuid-789',
'reason' => 'Network timeout',
'syncType' => 'full',
'batchSize' => 100,
]
);
// Call the protected method using reflection
$reflection = new \ReflectionClass($serviceMock);
$method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');
...
|
70626
|
NULL
|
NULL
|
NULL
|
|
70627
|
2536
|
6
|
2026-05-22T10:24:54.837474+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779445494837_m1.jpg...
|
PhpStorm
|
faVsco.js – ServiceTest.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20963-fix-import-on-de Project: faVsco.js, menu
JY-20963-fix-import-on-deleted-entity, menu
Start Listening for PHP Debug Connections
ServiceTest
Rerun 'PHPUnit: ServiceTest'
Debug 'ServiceTest'
Stop 'ServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Built-in Preview
Chrome
Firefox
Safari
Sync Changes
Hide This Notification
Code changed:
Hide
4
32
168
1
28
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Tests\Unit\Services\Crm\Salesforce;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Events\Dispatcher;
use Illuminate\Support\Facades\Cache;
use Jiminny\Component\Country\CountriesMap;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Enums\CrmObject;
use Jiminny\Exceptions\InvalidArgumentException;
use Jiminny\Exceptions\NoResultsException;
use Jiminny\Integrations\PlaybookResolver;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Playbook;
use Jiminny\Models\Team;
use Jiminny\Models\TeamSettings;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\ContactRoleRepository;
use Jiminny\Repositories\Crm\FieldDataRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\Salesforce\Client;
use Jiminny\Services\Crm\Salesforce\PayloadBuilder;
use Jiminny\Services\Crm\Salesforce\QueryBuilder;
use Jiminny\Services\Crm\Salesforce\QueryHandler;
use Jiminny\Services\Crm\Salesforce\QueryIterator;
use Jiminny\Services\Crm\Salesforce\QueryResults;
use Jiminny\Services\Crm\Salesforce\Service;
use PHPUnit\Framework\MockObject\MockObject;
use Tests\TestCase;
use Tests\Unit\Traits\TestPrivateMethod;
class ServiceTest extends TestCase
{
use TestPrivateMethod;
public function testFetchAndAssociateRelatedActivity(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$payloadBuilder->method('addCustomLogicFieldsPayload')
->willReturnCallback(function ($activity, $payload) {
return $payload;
});
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods(['fetchRelatedActivity', 'getPlaybook', 'getPlaybookCategory', 'updateRecord'])
->getMock();
$serviceMock->expects($this->once())
->method('fetchRelatedActivity')
->willReturn([
'Id' => 'testId',
'Type' => null,
'OwnerId' => 'testerUser',
'Description' => 'Test description',
]);
$user = $this->createMock(User::class);
$team = $this->createMock(Team::class);
$user->method('getAttribute')->with('team')->willReturn($team);
$playbook = $this->createMock(Playbook::class);
$playbook->method('getActivityField')->willReturn(null);
$playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_EVENT);
$serviceMock->expects($this->once())
->method('getPlaybook')
->with($user)
->willReturn($playbook);
$serviceMock->expects($this->never())
->method('getPlaybookCategory');
$serviceMock->expects($this->never())
->method('updateRecord');
$fieldDataRepository = $this->createMock(FieldDataRepository::class);
$fieldDataRepository->method('getActivityFieldData')->willReturn(collect([]));
app()->instance(FieldDataRepository::class, $fieldDataRepository);
$config = $this->createMock(Configuration::class);
$profilesRelation = $this->getMockBuilder(\Illuminate\Database\Eloquent\Relations\HasMany::class)
->disableOriginalConstructor()
->onlyMethods(['get'])
->addMethods(['where'])
->getMock();
$profilesRelation->method('where')->willReturnSelf();
$profilesRelation->method('get')->willReturn(collect([]));
$config->method('profiles')->willReturn($profilesRelation);
$serviceMock->config = $config;
$serviceMock->profile = null;
$actualStartTime = \Carbon\Carbon::now();
$activity = $this->getMockBuilder(Activity::class)
->disableOriginalConstructor()
->onlyMethods(['update', 'hasProspect'])
->getMock();
$activity->method('update')->willReturn(true);
$activity->method('hasProspect')->willReturn(true);
$activity->type = Activity::TYPE_CONFERENCE;
$activity->provider = Activity::PROVIDER_TWILIO;
$activity->lead_id = 1;
$activity->user_id = 0;
$activity->id_string = 'test-activity-id';
$activity->user = $user;
$activity->actual_start_time = $actualStartTime;
$activity->uuid = 'c53d8320-f556-4cee-a2f8-5f232f454ca4';
app()->bind(PlaybookResolver::class, function () use ($user) {
$playbook = $this->createMock(Playbook::class);
$playbookResolver = $this->createMock(PlaybookResolver::class);
$playbookResolver->expects($this->once())
->method('resolvePlaybookByUser')
->with($user)
->willReturn($playbook);
return $playbookResolver;
});
$data = $serviceMock->fetchAndAssociateRelatedActivity($activity);
$this->assertInstanceOf(Activity::class, $data);
$this->assertEquals(Activity::TYPE_CONFERENCE, $data->getType());
$this->assertEquals($actualStartTime->getTimestamp(), $data->getActualStartTime()->getTimestamp());
}
public function testFetchAndAssociateRelatedActivitySkipsForTaskBasedPlaybook(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods(['fetchRelatedActivity', 'getPlaybook'])
->getMock();
$user = $this->createMock(User::class);
$playbook = $this->createMock(Playbook::class);
$playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);
$playbook->method('getId')->willReturn(123);
$serviceMock->expects($this->once())
->method('getPlaybook')
->with($user)
->willReturn($playbook);
$serviceMock->expects($this->never())
->method('fetchRelatedActivity');
$activity = $this->getMockBuilder(Activity::class)
->disableOriginalConstructor()
->onlyMethods(['hasProspect', 'getUuid'])
->getMock();
$activity->method('hasProspect')->willReturn(true);
$activity->method('getUuid')->willReturn('c53d8320-f556-4cee-a2f8-5f232f454ca4');
$activity->type = Activity::TYPE_CONFERENCE;
$activity->actual_start_time = \Carbon\Carbon::now();
$activity->user = $user;
$result = $serviceMock->fetchAndAssociateRelatedActivity($activity);
$this->assertNull($result);
}
public function testFetchAndAssociateRelatedActivityReturnsNullForNonConference(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$serviceMock = new Service(
client: $client,
payloadBuilder: $payloadBuilder,
eventDispatcher: $eventDispatcher,
countriesMap: $countriesMap,
prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)
);
$activity = $this->getMockBuilder(Activity::class)
->disableOriginalConstructor()
->getMock();
$activity->type = Activity::TYPE_SOFTPHONE;
$result = $serviceMock->fetchAndAssociateRelatedActivity($activity);
$this->assertNull($result);
}
public function testFetchAndAssociateRelatedActivityReturnsNullWhenNoStartTime(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$serviceMock = new Service(
client: $client,
payloadBuilder: $payloadBuilder,
eventDispatcher: $eventDispatcher,
countriesMap: $countriesMap,
prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)
);
$activity = $this->getMockBuilder(Activity::class)
->disableOriginalConstructor()
->getMock();
$activity->type = Activity::TYPE_CONFERENCE;
$activity->actual_start_time = null;
$activity->scheduled_start_time = null;
$result = $serviceMock->fetchAndAssociateRelatedActivity($activity);
$this->assertNull($result);
}
public function testFetchAndAssociateRelatedActivityReturnsNullWhenNoProspect(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods(['getPlaybook'])
->getMock();
$serviceMock->expects($this->never())
->method('getPlaybook');
$activity = $this->getMockBuilder(Activity::class)
->disableOriginalConstructor()
->onlyMethods(['hasProspect', 'getUuid'])
->getMock();
$activity->method('hasProspect')->willReturn(false);
$activity->method('getUuid')->willReturn('c53d8320-f556-4cee-a2f8-5f232f454ca4');
$activity->type = Activity::TYPE_CONFERENCE;
$activity->actual_start_time = \Carbon\Carbon::now();
$result = $serviceMock->fetchAndAssociateRelatedActivity($activity);
$this->assertNull($result);
}
public function testMatchExactlyByEmail(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods([])
->getMock();
$profile = new Profile();
$profile->setAttribute('id', bin2hex(random_bytes(8)));
$serviceMock->profile = $profile;
$team = $this->createMock(Team::class);
$serviceMock->team = $team;
$data = $serviceMock->matchExactlyByEmail(bin2hex(random_bytes(8)) . '[EMAIL]');
$this->assertEquals(null, $data);
}
public function testMatchDomainFromEmail(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$queryIterator = $this->createMock(QueryIterator::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$config = $this->createMock(Configuration::class);
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('search')
->willReturn($queryIterator);
return $handler;
});
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods(['convertCrmData'])
->getMock();
$profile = new Profile();
$profile->account_fields = 'Field1, Field2, Field3';
$serviceMock->profile = $profile;
$serviceMock->expects($this->once())
->method('convertCrmData')
->willReturn(['test']);
$this->app->bind(QueryBuilder::class, function () {
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->expects($this->once())
->method('buildMatchByDomainQuery')
->with('[EMAIL]')
->willReturn('FIND {[EMAIL]} IN ALL FIELDS RETURNING Account(Id)');
return $queryBuilder;
});
$team = $this->createMock(Team::class);
$serviceMock->team = $team;
$serviceMock->config = $config;
$data = $serviceMock->matchByDomain('[EMAIL]');
$this->assertEquals(['test'], $data);
}
public function testBuildTaskSearchFields(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$service = new Service(
client: $client,
payloadBuilder: $payloadBuilder,
eventDispatcher: $eventDispatcher,
countriesMap: $countriesMap,
prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)
);
$fields = $service->buildTaskSearchFields();
$expectedFields = ['Id', 'WhoId', 'WhatId', 'AccountId'];
$this->assertEquals($expectedFields, $fields);
}
public function testMapCrmObjects(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$service = new Service(
client: $client,
payloadBuilder: $payloadBuilder,
eventDispatcher: $eventDispatcher,
countriesMap: $countriesMap,
prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class),
);
$sampleTask = [
'WhoId' => '003sampleWhoId',
'AccountId' => 'sampleAccountId',
'WhatId' => 'sampleWhatId',
];
$activityData = $service->mapCrmObjects($sampleTask);
$expectedActivityData = [
'contact' => '003sampleWhoId',
'account' => 'sampleAccountId',
'opportunity' => 'sampleWhatId',
];
$this->assertEquals($expectedActivityData, $activityData);
}
public function testGetInstalledAppVersion(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$queryIterator = $this->createMock(QueryIterator::class);
$queryIterator->expects($this->any())
->method('current')->willReturn([
'SubscriberPackageVersion' => [
'MajorVersion' => '1',
'MinorVersion' => '0',
'PatchVersion' => '1',
'BuildNumber' => '0',
],
]);
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('metadata')
->willReturn($queryIterator);
return $handler;
});
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods(array_diff(get_class_methods(Service::class), ['getInstalledAppVersion']))
->getMock();
$version = $serviceMock->getInstalledAppVersion();
$this->assertEquals('1010', $version);
}
public function testSyncProfiles(): void
{
$userToSearch = null;
$team = $this->createMock(Team::class);
$config = $this->createMockedConfiguration();
$config->expects($this->once())
->method('getId')
->willReturn(1);
$salesforceUser = [
'Email' => '[EMAIL]',
'UserPreferencesLightningExperiencePreferred' => true,
'CallCenterId' => '123',
'Id' => '456',
'ProfileId' => '789',
];
app()->bind(QueryBuilder::class, function () use ($userToSearch) {
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->expects($this->once())
->method('buildGetUsersQuery')
->with($userToSearch)
->willReturn('SELECT * FROM Users');
return $queryBuilder;
});
$queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('query')
->with('SELECT * FROM Users')
->willReturn($queryIterator);
return $handler;
});
$user = $this->createMock(User::class);
$user->expects($this->once())
->method('getId')
->willReturn(123);
$this->mockTeamRepository($team, $salesforceUser, $user);
$profileRepository = $this->createMock(ProfileRepository::class);
$profileRepository->expects($this->once())
->method('updateOrCreateProfile')
->with(
$user,
[
'crm_configuration_id' => 1,
'crm_provider_id' => '456',
],
[
'user_id' => 123,
'edition' => Profile::EDITION_LIGHTNING,
'has_external_cti' => true,
'crm_profile_id' => '789',
]
)
->willReturn(new Profile());
$this->app->instance(ProfileRepository::class, $profileRepository);
$serviceMock = $this->getServiceMock();
$serviceMock->team = $team;
$serviceMock->config = $config;
$result = $serviceMock->syncProfiles($userToSearch);
$this->assertNull($result);
}
public function testSyncProfilesEmailIsNull(): void
{
$userToSearch = $this->createMock(User::class);
$salesforceUser = [
'Email' => null,
];
app()->bind(QueryBuilder::class, function () {
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->expects($this->once())
->method('buildGetUsersQuery')
->with(null)
->willReturn('SELECT * FROM Users');
return $queryBuilder;
});
$queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->exactly(2))
->method('query')
->willReturn($queryIterator);
return $handler;
});
$team = $this->createMock(Team::class);
$user = $this->createMock(User::class);
$this->mockTeamRepository($team, $salesforceUser, $user, false);
$config = $this->createMock(Configuration::class);
$serviceMock = $this->getServiceMock();
$serviceMock->team = $team;
$serviceMock->config = $config;
$profile = $serviceMock->syncProfiles(null);
$this->assertNull($profile);
}
public function testSyncProfilesUserToSearchMatchesCurrentUser(): void
{
$userToSearch = $this->createMock(User::class);
$userToSearch->expects($this->once())
->method('getId')
->willReturn(123);
$team = $this->createMock(Team::class);
$config = $this->createMock(Configuration::class);
$config->expects($this->once())
->method('getId')
->willReturn(1);
$salesforceUser = [
'Email' => '[EMAIL]',
'UserPreferencesLightningExperiencePreferred' => true,
'CallCenterId' => '123',
'Id' => '456',
'ProfileId' => '789',
];
app()->bind(QueryBuilder::class, function () use ($userToSearch) {
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->expects($this->once())
->method('buildGetUsersQuery')
->with($userToSearch)
->willReturn('SELECT * FROM Users');
return $queryBuilder;
});
$queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('query')
->with('SELECT * FROM Users')
->willReturn($queryIterator);
return $handler;
});
$user = $this->createMock(User::class);
$user->expects($this->exactly(2))
->method('getId')
->willReturn(123);
$this->mockTeamRepository($team, $salesforceUser, $user);
$profileRepository = $this->createMock(ProfileRepository::class);
$profileRepository->expects($this->once())
->method('updateOrCreateProfile')
->with(
$user,
[
'crm_configuration_id' => 1,
'crm_provider_id' => '456',
],
[
'user_id' => 123,
'edition' => Profile::EDITION_LIGHTNING,
'has_external_cti' => true,
'crm_profile_id' => '789',
]
)
->willReturn(new Profile());
$this->app->instance(ProfileRepository::class, $profileRepository);
$serviceMock = $this->getServiceMock();
$serviceMock->team = $team;
$serviceMock->config = $config;
$profile = $serviceMock->syncProfiles($userToSearch);
$this->assertInstanceOf(Profile::class, $profile);
}
public function testSyncProfilesWithCustomValidation(): void
{
$userToSearch = null;
$team = $this->createMock(Team::class);
$config = $this->createMockedConfiguration();
$config->expects($this->atLeastOnce()) // Changed from once() to atLeastOnce()
->method('getId')
->willReturn(1);
$salesforceUser = [
'Email' => '[EMAIL]',
'UserPreferencesLightningExperiencePreferred' => true,
'CallCenterId' => '123',
'Id' => '456',
'ProfileId' => '789',
'CustomField' => 'CustomValue',
];
$customRules = [
['field' => 'CustomField', 'value' => 'CustomValue'],
];
$this->mockQueryBuilderAndHandler($userToSearch, [$salesforceUser]);
$user = $this->createMock(User::class);
$user->expects($this->once())
->method('getId')
->willReturn(123);
$this->mockTeamRepository($team, $salesforceUser, $user, true, $customRules);
$this->mockProfileRepository($user);
$serviceMock = $this->getServiceMock();
$serviceMock->team = $team;
$serviceMock->config = $config;
$profile = $serviceMock->syncProfiles($userToSearch);
$this->assertNull($profile);
}
public function testSyncProfilesWithCustomValidationFailing(): void
{
$userToSearch = null;
$team = $this->createMock(Team::class);
$config = $this->createMockedConfiguration();
$salesforceUser = [
'Email' => '[EMAIL]',
'UserPreferencesLightningExperiencePreferred' => true,
'CallCenterId' => '123',
'Id' => '456',
'ProfileId' => '789',
'CustomField' => 'WrongValue',
];
$customRules = [
['field' => 'CustomField', 'value' => 'CustomValue'],
];
$this->mockQueryBuilderAndHandler($userToSearch, [$salesforceUser]);
$teamRepository = $this->getMockForAbstractClass(TeamRepository::class, [], '', false, true, true, ['findActiveTeamMemberByEmail', 'getTeamSetting']);
$teamSettings = $this->createMock(TeamSettings::class);
$teamSettings->method('getValueType')
->willReturn('array');
$teamSettings->method('getValue')
->willReturn(json_encode($customRules));
$teamRepository->expects($this->once())
->method('getTeamSetting')
->with($team, 'custom_profile_validation')
->willReturn($teamSettings);
app()->bind(TeamRepository::class, function () use ($teamRepository) {
return $teamRepository;
});
$profileRepository = $this->createMock(ProfileRepository::class);
$profileRepository->expects($this->never())
->method('updateOrCreateProfile');
$this->app->instance(ProfileRepository::class, $profileRepository);
$serviceMock = $this->getServiceMock();
$serviceMock->team = $team;
$serviceMock->config = $config;
$result = $serviceMock->syncProfiles($userToSearch);
$this->assertNull($result);
}
public function testGetContactRolesFromCrm(): void
{
$contactRoles = [
[
'Id' => '1',
'ContactId' => 'Contact1',
'OpportunityId' => 'Opportunity1',
'Opportunity' => ['OwnerId' => 'Owner1'],
'IsPrimary' => true,
'Role' => 'Decision Maker',
],
];
$expectedResponse = [
[
'id' => '1',
'contactId' => 'Contact1',
'opportunityId' => 'Opportunity1',
'ownerId' => 'Owner1',
'isPrimary' => true,
'role' => 'Decision Maker',
],
];
$this->bindQueryIterator($contactRoles);
$serviceMock = $this->getServiceMock();
$data = $serviceMock->getContactRolesFromCrm(now()->subDay());
$this->assertEquals($expectedResponse, $data);
}
public function testGetContactRolesFromCrmNoResult(): void
{
$this->bindQueryIterator([]);
$serviceMock = $this->getServiceMock();
$data = $serviceMock->getContactRolesFromCrm(now()->subDay());
$this->assertEquals([], $data);
}
public function testSyncContactRoles(): void
{
$contactRoles = [
[
'id' => '1',
'contactId' => 'Contact1',
'opportunityId' => 'Opportunity1',
'ownerId' => 'Owner1',
'isPrimary' => true,
'role' => 'Decision Maker',
],
];
app()->bind(ContactRoleRepository::class, function () {
$contactRoleRepository = $this->createMock(ContactRoleRepository::class);
$contactRoleRepository->expects($this->once())
->method('saveContactRoles');
return $contactRoleRepository;
});
$serviceMock = $this->getServiceMock([
'getContactRolesFromCrm',
'syncRemotelyDeletedContactRoles',
'syncContact',
'syncOpportunity',
]);
$config = $this->createMock(Configuration::class);
$hasMany = $this->createMock(HasManyExtended::class);
$hasMany->expects($this->exactly(2))
->method('where')
->willReturn($hasMany);
$hasMany->expects($this->exactly(2))
->method('first')
->willReturn(
$this->createMock(Contact::class),
$this->createMock(Opportunity::class)
);
$config->expects($this->once())
->method('contacts')
->willReturn($hasMany);
$config->expects($this->once())
->method('opportunities')
->willReturn($hasMany);
$serviceMock->config = $config;
$serviceMock->expects($this->once())
->method('getContactRolesFromCrm')
->willReturn($contactRoles);
$serviceMock->expects($this->once())
->method('syncRemotelyDeletedContactRoles');
$data = $serviceMock->syncContactRoles(now()->subDay());
$this->assertEquals(1, $data);
}
public function testSyncRemotelyDeletedContactRoles(): void
{
$contactRoles = [
[
'id' => '1',
'crm_provider_id' => '1',
],
];
app()->bind(QueryHandler::class, function () use ($contactRoles) {
$queryResults = new QueryResults($contactRoles, 1, true, null);
$handler = $this->createMock(QueryHandler::class);
$handler->method('queryDeleted')
->willReturn($queryResults);
return $handler;
});
app()->bind(ContactRoleRepository::class, function () {
$contactRoleRepository = $this->createMock(ContactRoleRepository::class);
$contactRoleRepository->expects($this->once())
->method('deleteContactRoles');
return $contactRoleRepository;
});
$serviceMock = $this->getServiceMock();
$serviceMock->team = $this->createMock(Team::class);
$data = $this->invokePrivateMethod('syncRemotelyDeletedContactRoles', $serviceMock, []);
$this->assertTrue($data);
}
private function bindQueryIterator(array $queryResult): void
{
/** @var Client $client */
$client = $this->createMock(Client::class);
$queryIterator = new QueryIterator(
$client,
new QueryResults($queryResult, 1, true, null)
);
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('query')
->willReturn($queryIterator);
return $handler;
});
}
public static function getOpportunitySortOrderDataProvider(): array
{
return [
'all open recently updated' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED, ['LastModifiedDate DESC', true]],
'all open recently created' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED, ['CreatedDate DESC', true]],
'all open oldest created' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED, ['CreatedDate ASC', true]],
'all recently updated' => [Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED, ['LastModifiedDate DESC', false]],
'default' => ['unknown', ['LastModifiedDate DESC', true]],
];
}
private function createMockedConfiguration(): Configuration
{
$config = $this->createMock(Configuration::class);
$profilesRelation = $this->getMockBuilder(\Illuminate\Database\Eloquent\Relations\HasMany::class)
->disableOriginalConstructor()
->onlyMethods(['get'])
->addMethods(['where', 'first'])
->getMock();
$profilesRelation->method('where')->willReturnSelf();
$profilesRelation->method('get')->willReturn(collect([]));
$profilesRelation->method('first')->willReturn(null);
$config->method('profiles')->willReturn($profilesRelation);
return $config;
}
private function getServiceMock(array $onlyMethods = []): MockObject&Service
{
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$this->createMock(Client::class),
$this->createMock(PayloadBuilder::class),
$this->createMock(Dispatcher::class),
$this->createMock(CountriesMap::class),
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods($onlyMethods)
->getMock();
$serviceMock->profile = $this->createMock(Profile::class);
return $serviceMock;
}
private function mockQueryBuilderAndHandler($userToSearch, $salesforceUsers): void
{
app()->bind(QueryBuilder::class, function () use ($userToSearch) {
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->expects($this->once())
->method('buildGetUsersQuery')
->with($userToSearch)
->willReturn('SELECT * FROM Users');
return $queryBuilder;
});
$queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults($salesforceUsers, count($salesforceUsers), true, null));
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('query')
->willReturn($queryIterator);
return $handler;
});
}
private function mockTeamRepository(
Team $team,
array $salesforceUser,
?User $user = null,
bool $userSearch = true,
array $customRules = []
): void {
$teamRepository = $this->getMockForAbstractClass(TeamRepository::class, [], '', false, true, true, ['findActiveTeamMemberByEmail', 'getTeamSetting']);
if ($userSearch) {
$teamRepository->expects($this->once())
->method('findActiveTeamMemberByEmail')
->with($team, $salesforceUser['Email'])
->willReturn($user);
}
$teamSettings = $this->createMock(TeamSettings::class);
$teamSettings->method('getValueType')
->willReturn('array');
$teamSettings->method('getValue')
->willReturn(json_encode($customRules));
$teamRepository->expects($this->once())
->method('getTeamSetting')
->with($team, 'custom_profile_validation')
->willReturn($teamSettings);
app()->bind(TeamRepository::class, function () use ($teamRepository) {
return $teamRepository;
});
}
private function mockProfileRepository(User $user): void
{
$profileRepository = $this->createMock(ProfileRepository::class);
$profileRepository->expects($this->once())
->method('updateOrCreateProfile')
->with(
$user,
[
'crm_configuration_id' => 1,
'crm_provider_id' => '456',
],
[
'user_id' => 123,
'edition' => Profile::EDITION_LIGHTNING,
'has_external_cti' => true,
'crm_profile_id' => '789',
]
)
->willReturn(new Profile());
$this->app->instance(ProfileRepository::class, $profileRepository);
}
public function testBuildEnhancedNoteDecodesHtmlEntities(): void
{
$service = $this->getServiceMock(['createRecord']);
$profile = new Profile();
$profile->setAttribute('crm_provider_id', 'owner-123');
$service->profile = $profile;
$service->expects($this->exactly(2))
->method('createRecord')
->willReturnOnConsecutiveCalls('note-id-123', 'link-id-456');
$bodyWithEntities = 'Welch's current challenges and Facebook's Club';
$result = $this->invokePrivateMethod('buildEnhancedNote', $service, [
'Test Title',
$bodyWithEntities,
'object-id-789',
]);
$this->assertEquals('note-id-123', $result);
}
public function testBuildEnhancedNoteSanitizesWithoutQuotes(): void
{
$service = $this->getServiceMock(['createRecord']);
$profile = new Profile();
$profile->setAttribute('crm_provider_id', 'owner-456');
$service->profile = $profile;
$service->expects($this->exactly(2))
->method('createRecord')
->willReturnCallback(function ($type, $data) {
if ($type === 'ContentNote') {
$decoded = base64_decode($data['Content']);
$this->assertStringContainsString("Welch's", $decoded);
$this->assertStringNotContainsString(''', $decoded);
$this->assertStringNotContainsString('&#039;', $decoded);
$this->assertStringContainsString('<script>', $decoded);
return 'note-id-456';
}
return 'link-id-789';
});
$bodyWithMixedContent = "Welch's and <script>alert('xss')</script>";
$result = $this->invokePrivateMethod('buildEnhancedNote', $service, [
'Test Title',
$bodyWithMixedContent,
'object-id-123',
]);
$this->assertEquals('note-id-456', $result);
}
public function testBuildEnhancedNoteConvertsLineBreaks(): void
{
$service = $this->getServiceMock(['createRecord']);
$profile = new Profile();
$profile->setAttribute('crm_provider_id', 'owner-789');
$service->profile = $profile;
$service->expects($this->exactly(2))
->method('createRecord')
->willReturnCallback(function ($type, $data) {
if ($type === 'ContentNote') {
$decoded = base64_decode($data['Content']);
$this->assertStringContainsString('<br>', $decoded);
$this->assertStringNotContainsString('<br />', $decoded);
return 'note-id-789';
}
return 'link-id-012';
});
$bodyWithLineBreaks = "Line 1\nLine 2\nLine 3";
$result = $this->invokePrivateMethod('buildEnhancedNote', $service, [
'Test Title',
$bodyWithLineBreaks,
'object-id-456',
]);
$this->assertEquals('note-id-789', $result);
}
public function testBuildEnhancedNoteHandlesComplexScenario(): void
{
$service = $this->getServiceMock(['createRecord']);
$profile = new Profile();
$profile->setAttribute('crm_provider_id', 'owner-complex');
$service->profile = $profile;
$service->expects($this->exactly(2))
->method('createRecord')
->willReturnCallback(function ($type, $data) {
if ($type === 'ContentNote') {
$decoded = base64_decode($data['Content']);
$this->assertStringContainsString("Welch's", $decoded);
$this->assertStringContainsString("Facebook's Club", $decoded);
$this->assertStringContainsString("Arctics'", $decoded);
$this->assertStringNotContainsString(''', $decoded);
$this->assertStringNotContainsString('&#039;', $decoded);
$this->assertStringContainsString('<br>', $decoded);
$this->assertStringContainsString('<', $decoded);
$this->assertStringContainsString('>', $decoded);
return 'note-complex';
}
return 'link-complex';
});
$complexBody = "Summary:\n---------\nThe call focused on understanding Welch's current challenges and exploring how Arctics' Revenue Growth Management solutions could support their strategic goals.\n\n• John SMith discussed his role as a category advisor for Google and Facebook's Club, emphasizing the importance of market research and advising on product assortment.\n• Madona introduced Arctics' Virtual Shoppers AI, which simulates consumer <behavior> to optimize pricing and promotional strategies.";
$result = $this->invokePrivateMethod('buildEnhancedNote', $service, [
'Jiminny Transcription Summary',
$complexBody,
'task-id-001',
]);
$this->assertEquals('note-complex', $result);
}
public function testSyncRemotelyDeletedObjectsWithErrorHandlingSuccess(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$prospectPhotoPathService,
])
->onlyMethods(['syncRemotelyDeletedObjects'])
->getMock();
// Mock team
$team = $this->createMock(Team::class);
$team->method('getUuid')->willReturn('team-uuid-123');
$serviceMock->team = $team;
// Expect syncRemotelyDeletedObjects to be called once and succeed
$serviceMock->expects($this->once())
->method('syncRemotelyDeletedObjects')
->with(\Jiminny\Enums\CrmObject::ACCOUNT);
// Call the protected method using reflection
$reflection = new \ReflectionClass($serviceMock);
$method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');
$method->setAccessible(true);
// Should not throw any exceptions
$method->invoke($serviceMock, \Jiminny\Enums\CrmObject::ACCOUNT);
$this->assertTrue(true); // Test completed successfully
}
public function testSyncRemotelyDeletedObjectsWithErrorHandlingFailure(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$prospectPhotoPathService,
])
->onlyMethods(['syncRemotelyDeletedObjects'])
->getMock();
// Mock team
$team = $this->createMock(Team::class);
$team->method('getUuid')->willReturn('team-uuid-456');
$serviceMock->team = $team;
// Mock logger to verify warning is logged
$logger = $this->createMock(\Psr\Log\LoggerInterface::class);
// Use reflection to set the protected logger property
$reflection = new \ReflectionClass($serviceMock);
$loggerProperty = $reflection->getProperty('logger');
$loggerProperty->setAccessible(true);
$loggerProperty->setValue($serviceMock, $logger);
$exception = new \Exception('Sync failed due to API error');
// Expect syncRemotelyDeletedObjects to throw an exception
$serviceMock->expects($this->once())
->method('syncRemotelyDeletedObjects')
->with(\Jiminny\Enums\CrmObject::CONTACT)
->willThrowException($exception);
// Expect warning to be logged with correct message and parameters
$logger->expects($this->once())
->method('warning')
->with(
'[Salesforce] Remotely deleted objects sync failed',
[
'objectType' => 'contact',
'teamId' => 'team-uuid-456',
'reason' => 'Sync failed due to API error',
]
);
// Call the protected method using reflection
$reflection = new \ReflectionClass($serviceMock);
$method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');
$method->setAccessible(true);
// Should not re-throw the exception, just log it
$method->invoke($serviceMock, \Jiminny\Enums\CrmObject::CONTACT);
$this->assertTrue(true); // Test completed successfully
}
public function testSyncRemotelyDeletedObjectsWithErrorHandlingWithLogParams(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$prospectPhotoPathService,
])
->onlyMethods(['syncRemotelyDeletedObjects'])
->getMock();
// Mock team
$team = $this->createMock(Team::class);
$team->method('getUuid')->willReturn('team-uuid-789');
$serviceMock->team = $team;
// Mock logger to verify warning is logged
$logger = $this->createMock(\Psr\Log\LoggerInterface::class);
// Use reflection to set the protected logger property
$loggerReflection = new \ReflectionClass($serviceMock);
$loggerProperty = $loggerReflection->getProperty('logger');
$loggerProperty->setAccessible(true);
$loggerProperty->setValue($serviceMock, $logger);
$exception = new \Exception('Network timeout');
// Expect syncRemotelyDeletedObjects to throw an exception
$serviceMock->expects($this->once())
->method('syncRemotelyDeletedObjects')
->with(\Jiminny\Enums\CrmObject::OPPORTUNITY)
->willThrowException($exception);
// Additional log parameters
$logParams = [
'syncType' => 'full',
'batchSize' => 100,
];
// Expect warning to be logged with merged parameters
$logger->expects($this->once())
->method('warning')
->with(
'[Salesforce] Remotely deleted objects sync failed',
[
'objectType' => 'opportunity',
'teamId' => 'team-uuid-789',
'reason' => 'Network timeout',
'syncType' => 'full',
'batchSize' => 100,
]
);
// Call the protected method using reflection
$reflection = new \ReflectionClass($serviceMock);
$method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');
...
|
[{"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-20963-fix-import-on-deleted-entity, menu","depth":5,"on_screen":true,"help_text":"Git Branch: JY-20963-fix-import-on-deleted-entity","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"ServiceTest","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Rerun 'PHPUnit: ServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'ServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Stop 'ServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Built-in Preview","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":"Chrome","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Firefox","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Safari","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"4","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"32","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"168","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"28","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Services\\Crm\\Salesforce;\n\nuse Illuminate\\Database\\Eloquent\\Relations\\HasMany;\nuse Illuminate\\Events\\Dispatcher;\nuse Illuminate\\Support\\Facades\\Cache;\nuse Jiminny\\Component\\Country\\CountriesMap;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Enums\\CrmObject;\nuse Jiminny\\Exceptions\\InvalidArgumentException;\nuse Jiminny\\Exceptions\\NoResultsException;\nuse Jiminny\\Integrations\\PlaybookResolver;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Crm\\Configuration;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\TeamSettings;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\ContactRoleRepository;\nuse Jiminny\\Repositories\\Crm\\FieldDataRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\Salesforce\\Client;\nuse Jiminny\\Services\\Crm\\Salesforce\\PayloadBuilder;\nuse Jiminny\\Services\\Crm\\Salesforce\\QueryBuilder;\nuse Jiminny\\Services\\Crm\\Salesforce\\QueryHandler;\nuse Jiminny\\Services\\Crm\\Salesforce\\QueryIterator;\nuse Jiminny\\Services\\Crm\\Salesforce\\QueryResults;\nuse Jiminny\\Services\\Crm\\Salesforce\\Service;\nuse PHPUnit\\Framework\\MockObject\\MockObject;\nuse Tests\\TestCase;\nuse Tests\\Unit\\Traits\\TestPrivateMethod;\n\nclass ServiceTest extends TestCase\n{\n use TestPrivateMethod;\n\n public function testFetchAndAssociateRelatedActivity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $payloadBuilder->method('addCustomLogicFieldsPayload')\n ->willReturnCallback(function ($activity, $payload) {\n return $payload;\n });\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods(['fetchRelatedActivity', 'getPlaybook', 'getPlaybookCategory', 'updateRecord'])\n ->getMock();\n\n $serviceMock->expects($this->once())\n ->method('fetchRelatedActivity')\n ->willReturn([\n 'Id' => 'testId',\n 'Type' => null,\n 'OwnerId' => 'testerUser',\n 'Description' => 'Test description',\n ]);\n\n $user = $this->createMock(User::class);\n $team = $this->createMock(Team::class);\n $user->method('getAttribute')->with('team')->willReturn($team);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityField')->willReturn(null);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_EVENT);\n\n $serviceMock->expects($this->once())\n ->method('getPlaybook')\n ->with($user)\n ->willReturn($playbook);\n\n $serviceMock->expects($this->never())\n ->method('getPlaybookCategory');\n\n $serviceMock->expects($this->never())\n ->method('updateRecord');\n\n $fieldDataRepository = $this->createMock(FieldDataRepository::class);\n $fieldDataRepository->method('getActivityFieldData')->willReturn(collect([]));\n app()->instance(FieldDataRepository::class, $fieldDataRepository);\n\n $config = $this->createMock(Configuration::class);\n $profilesRelation = $this->getMockBuilder(\\Illuminate\\Database\\Eloquent\\Relations\\HasMany::class)\n ->disableOriginalConstructor()\n ->onlyMethods(['get'])\n ->addMethods(['where'])\n ->getMock();\n $profilesRelation->method('where')->willReturnSelf();\n $profilesRelation->method('get')->willReturn(collect([]));\n $config->method('profiles')->willReturn($profilesRelation);\n\n $serviceMock->config = $config;\n $serviceMock->profile = null;\n\n $actualStartTime = \\Carbon\\Carbon::now();\n\n $activity = $this->getMockBuilder(Activity::class)\n ->disableOriginalConstructor()\n ->onlyMethods(['update', 'hasProspect'])\n ->getMock();\n\n $activity->method('update')->willReturn(true);\n $activity->method('hasProspect')->willReturn(true);\n\n $activity->type = Activity::TYPE_CONFERENCE;\n $activity->provider = Activity::PROVIDER_TWILIO;\n $activity->lead_id = 1;\n $activity->user_id = 0;\n $activity->id_string = 'test-activity-id';\n $activity->user = $user;\n\n $activity->actual_start_time = $actualStartTime;\n $activity->uuid = 'c53d8320-f556-4cee-a2f8-5f232f454ca4';\n\n app()->bind(PlaybookResolver::class, function () use ($user) {\n $playbook = $this->createMock(Playbook::class);\n $playbookResolver = $this->createMock(PlaybookResolver::class);\n $playbookResolver->expects($this->once())\n ->method('resolvePlaybookByUser')\n ->with($user)\n ->willReturn($playbook);\n\n return $playbookResolver;\n });\n\n $data = $serviceMock->fetchAndAssociateRelatedActivity($activity);\n\n $this->assertInstanceOf(Activity::class, $data);\n $this->assertEquals(Activity::TYPE_CONFERENCE, $data->getType());\n $this->assertEquals($actualStartTime->getTimestamp(), $data->getActualStartTime()->getTimestamp());\n }\n\n public function testFetchAndAssociateRelatedActivitySkipsForTaskBasedPlaybook(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods(['fetchRelatedActivity', 'getPlaybook'])\n ->getMock();\n\n $user = $this->createMock(User::class);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n $playbook->method('getId')->willReturn(123);\n\n $serviceMock->expects($this->once())\n ->method('getPlaybook')\n ->with($user)\n ->willReturn($playbook);\n\n $serviceMock->expects($this->never())\n ->method('fetchRelatedActivity');\n\n $activity = $this->getMockBuilder(Activity::class)\n ->disableOriginalConstructor()\n ->onlyMethods(['hasProspect', 'getUuid'])\n ->getMock();\n\n $activity->method('hasProspect')->willReturn(true);\n $activity->method('getUuid')->willReturn('c53d8320-f556-4cee-a2f8-5f232f454ca4');\n $activity->type = Activity::TYPE_CONFERENCE;\n $activity->actual_start_time = \\Carbon\\Carbon::now();\n $activity->user = $user;\n\n $result = $serviceMock->fetchAndAssociateRelatedActivity($activity);\n\n $this->assertNull($result);\n }\n\n public function testFetchAndAssociateRelatedActivityReturnsNullForNonConference(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $serviceMock = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)\n );\n\n $activity = $this->getMockBuilder(Activity::class)\n ->disableOriginalConstructor()\n ->getMock();\n\n $activity->type = Activity::TYPE_SOFTPHONE;\n\n $result = $serviceMock->fetchAndAssociateRelatedActivity($activity);\n\n $this->assertNull($result);\n }\n\n public function testFetchAndAssociateRelatedActivityReturnsNullWhenNoStartTime(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $serviceMock = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)\n );\n\n\n $activity = $this->getMockBuilder(Activity::class)\n ->disableOriginalConstructor()\n ->getMock();\n\n $activity->type = Activity::TYPE_CONFERENCE;\n $activity->actual_start_time = null;\n $activity->scheduled_start_time = null;\n\n $result = $serviceMock->fetchAndAssociateRelatedActivity($activity);\n\n $this->assertNull($result);\n }\n\n public function testFetchAndAssociateRelatedActivityReturnsNullWhenNoProspect(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods(['getPlaybook'])\n ->getMock();\n\n $serviceMock->expects($this->never())\n ->method('getPlaybook');\n\n $activity = $this->getMockBuilder(Activity::class)\n ->disableOriginalConstructor()\n ->onlyMethods(['hasProspect', 'getUuid'])\n ->getMock();\n\n $activity->method('hasProspect')->willReturn(false);\n $activity->method('getUuid')->willReturn('c53d8320-f556-4cee-a2f8-5f232f454ca4');\n $activity->type = Activity::TYPE_CONFERENCE;\n $activity->actual_start_time = \\Carbon\\Carbon::now();\n\n $result = $serviceMock->fetchAndAssociateRelatedActivity($activity);\n\n $this->assertNull($result);\n }\n\n public function testMatchExactlyByEmail(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods([])\n ->getMock();\n\n $profile = new Profile();\n $profile->setAttribute('id', bin2hex(random_bytes(8)));\n $serviceMock->profile = $profile;\n\n $team = $this->createMock(Team::class);\n $serviceMock->team = $team;\n\n $data = $serviceMock->matchExactlyByEmail(bin2hex(random_bytes(8)) . 'test_email@testserver.com');\n\n $this->assertEquals(null, $data);\n }\n\n public function testMatchDomainFromEmail(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $queryIterator = $this->createMock(QueryIterator::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $config = $this->createMock(Configuration::class);\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('search')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods(['convertCrmData'])\n ->getMock();\n\n $profile = new Profile();\n $profile->account_fields = 'Field1, Field2, Field3';\n $serviceMock->profile = $profile;\n\n $serviceMock->expects($this->once())\n ->method('convertCrmData')\n ->willReturn(['test']);\n\n $this->app->bind(QueryBuilder::class, function () {\n $queryBuilder = $this->createMock(QueryBuilder::class);\n $queryBuilder->expects($this->once())\n ->method('buildMatchByDomainQuery')\n ->with('test_email@testserver.com')\n ->willReturn('FIND {test_email@testserver.com} IN ALL FIELDS RETURNING Account(Id)');\n\n return $queryBuilder;\n });\n\n $team = $this->createMock(Team::class);\n\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n\n $data = $serviceMock->matchByDomain('test_email@testserver.com');\n\n $this->assertEquals(['test'], $data);\n }\n\n public function testBuildTaskSearchFields(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)\n );\n\n $fields = $service->buildTaskSearchFields();\n\n $expectedFields = ['Id', 'WhoId', 'WhatId', 'AccountId'];\n\n $this->assertEquals($expectedFields, $fields);\n }\n\n public function testMapCrmObjects(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class),\n );\n\n $sampleTask = [\n 'WhoId' => '003sampleWhoId',\n 'AccountId' => 'sampleAccountId',\n 'WhatId' => 'sampleWhatId',\n ];\n\n $activityData = $service->mapCrmObjects($sampleTask);\n\n $expectedActivityData = [\n 'contact' => '003sampleWhoId',\n 'account' => 'sampleAccountId',\n 'opportunity' => 'sampleWhatId',\n ];\n\n $this->assertEquals($expectedActivityData, $activityData);\n }\n\n public function testGetInstalledAppVersion(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n $queryIterator = $this->createMock(QueryIterator::class);\n $queryIterator->expects($this->any())\n ->method('current')->willReturn([\n 'SubscriberPackageVersion' => [\n 'MajorVersion' => '1',\n 'MinorVersion' => '0',\n 'PatchVersion' => '1',\n 'BuildNumber' => '0',\n ],\n ]);\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('metadata')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods(array_diff(get_class_methods(Service::class), ['getInstalledAppVersion']))\n ->getMock();\n\n $version = $serviceMock->getInstalledAppVersion();\n\n $this->assertEquals('1010', $version);\n }\n\n public function testSyncProfiles(): void\n {\n $userToSearch = null;\n\n $team = $this->createMock(Team::class);\n $config = $this->createMockedConfiguration();\n $config->expects($this->once())\n ->method('getId')\n ->willReturn(1);\n\n $salesforceUser = [\n 'Email' => 'test@example.com',\n 'UserPreferencesLightningExperiencePreferred' => true,\n 'CallCenterId' => '123',\n 'Id' => '456',\n 'ProfileId' => '789',\n ];\n\n app()->bind(QueryBuilder::class, function () use ($userToSearch) {\n $queryBuilder = $this->createMock(QueryBuilder::class);\n $queryBuilder->expects($this->once())\n ->method('buildGetUsersQuery')\n ->with($userToSearch)\n ->willReturn('SELECT * FROM Users');\n\n return $queryBuilder;\n });\n\n $queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('query')\n ->with('SELECT * FROM Users')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n\n $user = $this->createMock(User::class);\n $user->expects($this->once())\n ->method('getId')\n ->willReturn(123);\n\n $this->mockTeamRepository($team, $salesforceUser, $user);\n\n $profileRepository = $this->createMock(ProfileRepository::class);\n $profileRepository->expects($this->once())\n ->method('updateOrCreateProfile')\n ->with(\n $user,\n [\n 'crm_configuration_id' => 1,\n 'crm_provider_id' => '456',\n ],\n [\n 'user_id' => 123,\n 'edition' => Profile::EDITION_LIGHTNING,\n 'has_external_cti' => true,\n 'crm_profile_id' => '789',\n ]\n )\n ->willReturn(new Profile());\n\n $this->app->instance(ProfileRepository::class, $profileRepository);\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n $result = $serviceMock->syncProfiles($userToSearch);\n\n $this->assertNull($result);\n }\n\n public function testSyncProfilesEmailIsNull(): void\n {\n $userToSearch = $this->createMock(User::class);\n\n $salesforceUser = [\n 'Email' => null,\n ];\n\n app()->bind(QueryBuilder::class, function () {\n $queryBuilder = $this->createMock(QueryBuilder::class);\n $queryBuilder->expects($this->once())\n ->method('buildGetUsersQuery')\n ->with(null)\n ->willReturn('SELECT * FROM Users');\n\n return $queryBuilder;\n });\n\n $queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->exactly(2))\n ->method('query')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n\n $team = $this->createMock(Team::class);\n $user = $this->createMock(User::class);\n $this->mockTeamRepository($team, $salesforceUser, $user, false);\n\n $config = $this->createMock(Configuration::class);\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n\n $profile = $serviceMock->syncProfiles(null);\n\n $this->assertNull($profile);\n }\n\n public function testSyncProfilesUserToSearchMatchesCurrentUser(): void\n {\n $userToSearch = $this->createMock(User::class);\n $userToSearch->expects($this->once())\n ->method('getId')\n ->willReturn(123);\n\n $team = $this->createMock(Team::class);\n $config = $this->createMock(Configuration::class);\n $config->expects($this->once())\n ->method('getId')\n ->willReturn(1);\n\n $salesforceUser = [\n 'Email' => 'test@example.com',\n 'UserPreferencesLightningExperiencePreferred' => true,\n 'CallCenterId' => '123',\n 'Id' => '456',\n 'ProfileId' => '789',\n ];\n\n app()->bind(QueryBuilder::class, function () use ($userToSearch) {\n $queryBuilder = $this->createMock(QueryBuilder::class);\n $queryBuilder->expects($this->once())\n ->method('buildGetUsersQuery')\n ->with($userToSearch)\n ->willReturn('SELECT * FROM Users');\n\n return $queryBuilder;\n });\n\n $queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('query')\n ->with('SELECT * FROM Users')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n\n $user = $this->createMock(User::class);\n $user->expects($this->exactly(2))\n ->method('getId')\n ->willReturn(123);\n\n $this->mockTeamRepository($team, $salesforceUser, $user);\n\n $profileRepository = $this->createMock(ProfileRepository::class);\n $profileRepository->expects($this->once())\n ->method('updateOrCreateProfile')\n ->with(\n $user,\n [\n 'crm_configuration_id' => 1,\n 'crm_provider_id' => '456',\n ],\n [\n 'user_id' => 123,\n 'edition' => Profile::EDITION_LIGHTNING,\n 'has_external_cti' => true,\n 'crm_profile_id' => '789',\n ]\n )\n ->willReturn(new Profile());\n\n $this->app->instance(ProfileRepository::class, $profileRepository);\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n $profile = $serviceMock->syncProfiles($userToSearch);\n\n $this->assertInstanceOf(Profile::class, $profile);\n }\n\n public function testSyncProfilesWithCustomValidation(): void\n {\n $userToSearch = null;\n\n $team = $this->createMock(Team::class);\n $config = $this->createMockedConfiguration();\n $config->expects($this->atLeastOnce()) // Changed from once() to atLeastOnce()\n ->method('getId')\n ->willReturn(1);\n\n $salesforceUser = [\n 'Email' => 'test@example.com',\n 'UserPreferencesLightningExperiencePreferred' => true,\n 'CallCenterId' => '123',\n 'Id' => '456',\n 'ProfileId' => '789',\n 'CustomField' => 'CustomValue',\n ];\n\n $customRules = [\n ['field' => 'CustomField', 'value' => 'CustomValue'],\n ];\n\n $this->mockQueryBuilderAndHandler($userToSearch, [$salesforceUser]);\n\n $user = $this->createMock(User::class);\n $user->expects($this->once())\n ->method('getId')\n ->willReturn(123);\n\n $this->mockTeamRepository($team, $salesforceUser, $user, true, $customRules);\n\n $this->mockProfileRepository($user);\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n $profile = $serviceMock->syncProfiles($userToSearch);\n\n $this->assertNull($profile);\n }\n\n public function testSyncProfilesWithCustomValidationFailing(): void\n {\n $userToSearch = null;\n\n $team = $this->createMock(Team::class);\n $config = $this->createMockedConfiguration();\n\n $salesforceUser = [\n 'Email' => 'test@example.com',\n 'UserPreferencesLightningExperiencePreferred' => true,\n 'CallCenterId' => '123',\n 'Id' => '456',\n 'ProfileId' => '789',\n 'CustomField' => 'WrongValue',\n ];\n\n $customRules = [\n ['field' => 'CustomField', 'value' => 'CustomValue'],\n ];\n\n $this->mockQueryBuilderAndHandler($userToSearch, [$salesforceUser]);\n\n $teamRepository = $this->getMockForAbstractClass(TeamRepository::class, [], '', false, true, true, ['findActiveTeamMemberByEmail', 'getTeamSetting']);\n\n $teamSettings = $this->createMock(TeamSettings::class);\n $teamSettings->method('getValueType')\n ->willReturn('array');\n\n $teamSettings->method('getValue')\n ->willReturn(json_encode($customRules));\n\n $teamRepository->expects($this->once())\n ->method('getTeamSetting')\n ->with($team, 'custom_profile_validation')\n ->willReturn($teamSettings);\n\n app()->bind(TeamRepository::class, function () use ($teamRepository) {\n return $teamRepository;\n });\n\n $profileRepository = $this->createMock(ProfileRepository::class);\n $profileRepository->expects($this->never())\n ->method('updateOrCreateProfile');\n\n $this->app->instance(ProfileRepository::class, $profileRepository);\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n $result = $serviceMock->syncProfiles($userToSearch);\n\n $this->assertNull($result);\n }\n\n public function testGetContactRolesFromCrm(): void\n {\n $contactRoles = [\n [\n 'Id' => '1',\n 'ContactId' => 'Contact1',\n 'OpportunityId' => 'Opportunity1',\n 'Opportunity' => ['OwnerId' => 'Owner1'],\n 'IsPrimary' => true,\n 'Role' => 'Decision Maker',\n ],\n ];\n\n $expectedResponse = [\n [\n 'id' => '1',\n 'contactId' => 'Contact1',\n 'opportunityId' => 'Opportunity1',\n 'ownerId' => 'Owner1',\n 'isPrimary' => true,\n 'role' => 'Decision Maker',\n ],\n ];\n\n $this->bindQueryIterator($contactRoles);\n\n $serviceMock = $this->getServiceMock();\n\n $data = $serviceMock->getContactRolesFromCrm(now()->subDay());\n\n $this->assertEquals($expectedResponse, $data);\n }\n\n public function testGetContactRolesFromCrmNoResult(): void\n {\n $this->bindQueryIterator([]);\n\n $serviceMock = $this->getServiceMock();\n\n $data = $serviceMock->getContactRolesFromCrm(now()->subDay());\n\n $this->assertEquals([], $data);\n }\n\n public function testSyncContactRoles(): void\n {\n $contactRoles = [\n [\n 'id' => '1',\n 'contactId' => 'Contact1',\n 'opportunityId' => 'Opportunity1',\n 'ownerId' => 'Owner1',\n 'isPrimary' => true,\n 'role' => 'Decision Maker',\n ],\n ];\n\n app()->bind(ContactRoleRepository::class, function () {\n $contactRoleRepository = $this->createMock(ContactRoleRepository::class);\n $contactRoleRepository->expects($this->once())\n ->method('saveContactRoles');\n\n return $contactRoleRepository;\n });\n\n $serviceMock = $this->getServiceMock([\n 'getContactRolesFromCrm',\n 'syncRemotelyDeletedContactRoles',\n 'syncContact',\n 'syncOpportunity',\n ]);\n\n $config = $this->createMock(Configuration::class);\n $hasMany = $this->createMock(HasManyExtended::class);\n $hasMany->expects($this->exactly(2))\n ->method('where')\n ->willReturn($hasMany);\n\n $hasMany->expects($this->exactly(2))\n ->method('first')\n ->willReturn(\n $this->createMock(Contact::class),\n $this->createMock(Opportunity::class)\n );\n\n $config->expects($this->once())\n ->method('contacts')\n ->willReturn($hasMany);\n $config->expects($this->once())\n ->method('opportunities')\n ->willReturn($hasMany);\n\n $serviceMock->config = $config;\n\n $serviceMock->expects($this->once())\n ->method('getContactRolesFromCrm')\n ->willReturn($contactRoles);\n\n $serviceMock->expects($this->once())\n ->method('syncRemotelyDeletedContactRoles');\n\n $data = $serviceMock->syncContactRoles(now()->subDay());\n\n $this->assertEquals(1, $data);\n }\n\n public function testSyncRemotelyDeletedContactRoles(): void\n {\n $contactRoles = [\n [\n 'id' => '1',\n 'crm_provider_id' => '1',\n ],\n ];\n\n app()->bind(QueryHandler::class, function () use ($contactRoles) {\n $queryResults = new QueryResults($contactRoles, 1, true, null);\n\n $handler = $this->createMock(QueryHandler::class);\n $handler->method('queryDeleted')\n ->willReturn($queryResults);\n\n return $handler;\n });\n\n app()->bind(ContactRoleRepository::class, function () {\n $contactRoleRepository = $this->createMock(ContactRoleRepository::class);\n $contactRoleRepository->expects($this->once())\n ->method('deleteContactRoles');\n\n return $contactRoleRepository;\n });\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $this->createMock(Team::class);\n\n $data = $this->invokePrivateMethod('syncRemotelyDeletedContactRoles', $serviceMock, []);\n\n $this->assertTrue($data);\n }\n\n private function bindQueryIterator(array $queryResult): void\n {\n /** @var Client $client */\n $client = $this->createMock(Client::class);\n $queryIterator = new QueryIterator(\n $client,\n new QueryResults($queryResult, 1, true, null)\n );\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('query')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n }\n\n public static function getOpportunitySortOrderDataProvider(): array\n {\n return [\n 'all open recently updated' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED, ['LastModifiedDate DESC', true]],\n 'all open recently created' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED, ['CreatedDate DESC', true]],\n 'all open oldest created' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED, ['CreatedDate ASC', true]],\n 'all recently updated' => [Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED, ['LastModifiedDate DESC', false]],\n 'default' => ['unknown', ['LastModifiedDate DESC', true]],\n ];\n }\n\n private function createMockedConfiguration(): Configuration\n {\n $config = $this->createMock(Configuration::class);\n $profilesRelation = $this->getMockBuilder(\\Illuminate\\Database\\Eloquent\\Relations\\HasMany::class)\n ->disableOriginalConstructor()\n ->onlyMethods(['get'])\n ->addMethods(['where', 'first'])\n ->getMock();\n $profilesRelation->method('where')->willReturnSelf();\n $profilesRelation->method('get')->willReturn(collect([]));\n $profilesRelation->method('first')->willReturn(null);\n $config->method('profiles')->willReturn($profilesRelation);\n\n return $config;\n }\n\n private function getServiceMock(array $onlyMethods = []): MockObject&Service\n {\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $this->createMock(Client::class),\n $this->createMock(PayloadBuilder::class),\n $this->createMock(Dispatcher::class),\n $this->createMock(CountriesMap::class),\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods($onlyMethods)\n ->getMock();\n\n $serviceMock->profile = $this->createMock(Profile::class);\n\n return $serviceMock;\n }\n\n private function mockQueryBuilderAndHandler($userToSearch, $salesforceUsers): void\n {\n app()->bind(QueryBuilder::class, function () use ($userToSearch) {\n $queryBuilder = $this->createMock(QueryBuilder::class);\n $queryBuilder->expects($this->once())\n ->method('buildGetUsersQuery')\n ->with($userToSearch)\n ->willReturn('SELECT * FROM Users');\n\n return $queryBuilder;\n });\n\n $queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults($salesforceUsers, count($salesforceUsers), true, null));\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('query')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n }\n\n private function mockTeamRepository(\n Team $team,\n array $salesforceUser,\n ?User $user = null,\n bool $userSearch = true,\n array $customRules = []\n ): void {\n $teamRepository = $this->getMockForAbstractClass(TeamRepository::class, [], '', false, true, true, ['findActiveTeamMemberByEmail', 'getTeamSetting']);\n\n if ($userSearch) {\n $teamRepository->expects($this->once())\n ->method('findActiveTeamMemberByEmail')\n ->with($team, $salesforceUser['Email'])\n ->willReturn($user);\n }\n\n $teamSettings = $this->createMock(TeamSettings::class);\n $teamSettings->method('getValueType')\n ->willReturn('array');\n\n $teamSettings->method('getValue')\n ->willReturn(json_encode($customRules));\n\n $teamRepository->expects($this->once())\n ->method('getTeamSetting')\n ->with($team, 'custom_profile_validation')\n ->willReturn($teamSettings);\n\n app()->bind(TeamRepository::class, function () use ($teamRepository) {\n return $teamRepository;\n });\n }\n\n private function mockProfileRepository(User $user): void\n {\n $profileRepository = $this->createMock(ProfileRepository::class);\n $profileRepository->expects($this->once())\n ->method('updateOrCreateProfile')\n ->with(\n $user,\n [\n 'crm_configuration_id' => 1,\n 'crm_provider_id' => '456',\n ],\n [\n 'user_id' => 123,\n 'edition' => Profile::EDITION_LIGHTNING,\n 'has_external_cti' => true,\n 'crm_profile_id' => '789',\n ]\n )\n ->willReturn(new Profile());\n\n $this->app->instance(ProfileRepository::class, $profileRepository);\n }\n\n public function testBuildEnhancedNoteDecodesHtmlEntities(): void\n {\n $service = $this->getServiceMock(['createRecord']);\n\n $profile = new Profile();\n $profile->setAttribute('crm_provider_id', 'owner-123');\n $service->profile = $profile;\n\n $service->expects($this->exactly(2))\n ->method('createRecord')\n ->willReturnOnConsecutiveCalls('note-id-123', 'link-id-456');\n\n $bodyWithEntities = 'Welch's current challenges and Facebook's Club';\n\n $result = $this->invokePrivateMethod('buildEnhancedNote', $service, [\n 'Test Title',\n $bodyWithEntities,\n 'object-id-789',\n ]);\n\n $this->assertEquals('note-id-123', $result);\n }\n\n public function testBuildEnhancedNoteSanitizesWithoutQuotes(): void\n {\n $service = $this->getServiceMock(['createRecord']);\n\n $profile = new Profile();\n $profile->setAttribute('crm_provider_id', 'owner-456');\n $service->profile = $profile;\n\n $service->expects($this->exactly(2))\n ->method('createRecord')\n ->willReturnCallback(function ($type, $data) {\n if ($type === 'ContentNote') {\n $decoded = base64_decode($data['Content']);\n $this->assertStringContainsString(\"Welch's\", $decoded);\n $this->assertStringNotContainsString(''', $decoded);\n $this->assertStringNotContainsString('&#039;', $decoded);\n $this->assertStringContainsString('<script>', $decoded);\n\n return 'note-id-456';\n }\n\n return 'link-id-789';\n });\n\n $bodyWithMixedContent = \"Welch's and <script>alert('xss')</script>\";\n\n $result = $this->invokePrivateMethod('buildEnhancedNote', $service, [\n 'Test Title',\n $bodyWithMixedContent,\n 'object-id-123',\n ]);\n\n $this->assertEquals('note-id-456', $result);\n }\n\n public function testBuildEnhancedNoteConvertsLineBreaks(): void\n {\n $service = $this->getServiceMock(['createRecord']);\n\n $profile = new Profile();\n $profile->setAttribute('crm_provider_id', 'owner-789');\n $service->profile = $profile;\n\n $service->expects($this->exactly(2))\n ->method('createRecord')\n ->willReturnCallback(function ($type, $data) {\n if ($type === 'ContentNote') {\n $decoded = base64_decode($data['Content']);\n $this->assertStringContainsString('<br>', $decoded);\n $this->assertStringNotContainsString('<br />', $decoded);\n\n return 'note-id-789';\n }\n\n return 'link-id-012';\n });\n\n $bodyWithLineBreaks = \"Line 1\\nLine 2\\nLine 3\";\n\n $result = $this->invokePrivateMethod('buildEnhancedNote', $service, [\n 'Test Title',\n $bodyWithLineBreaks,\n 'object-id-456',\n ]);\n\n $this->assertEquals('note-id-789', $result);\n }\n\n public function testBuildEnhancedNoteHandlesComplexScenario(): void\n {\n $service = $this->getServiceMock(['createRecord']);\n\n $profile = new Profile();\n $profile->setAttribute('crm_provider_id', 'owner-complex');\n $service->profile = $profile;\n\n $service->expects($this->exactly(2))\n ->method('createRecord')\n ->willReturnCallback(function ($type, $data) {\n if ($type === 'ContentNote') {\n $decoded = base64_decode($data['Content']);\n\n $this->assertStringContainsString(\"Welch's\", $decoded);\n $this->assertStringContainsString(\"Facebook's Club\", $decoded);\n $this->assertStringContainsString(\"Arctics'\", $decoded);\n $this->assertStringNotContainsString(''', $decoded);\n $this->assertStringNotContainsString('&#039;', $decoded);\n $this->assertStringContainsString('<br>', $decoded);\n $this->assertStringContainsString('<', $decoded);\n $this->assertStringContainsString('>', $decoded);\n\n return 'note-complex';\n }\n\n return 'link-complex';\n });\n\n $complexBody = \"Summary:\\n---------\\nThe call focused on understanding Welch's current challenges and exploring how Arctics' Revenue Growth Management solutions could support their strategic goals.\\n\\n• John SMith discussed his role as a category advisor for Google and Facebook's Club, emphasizing the importance of market research and advising on product assortment.\\n• Madona introduced Arctics' Virtual Shoppers AI, which simulates consumer <behavior> to optimize pricing and promotional strategies.\";\n\n $result = $this->invokePrivateMethod('buildEnhancedNote', $service, [\n 'Jiminny Transcription Summary',\n $complexBody,\n 'task-id-001',\n ]);\n\n $this->assertEquals('note-complex', $result);\n }\n\n public function testSyncRemotelyDeletedObjectsWithErrorHandlingSuccess(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['syncRemotelyDeletedObjects'])\n ->getMock();\n\n // Mock team\n $team = $this->createMock(Team::class);\n $team->method('getUuid')->willReturn('team-uuid-123');\n $serviceMock->team = $team;\n\n // Expect syncRemotelyDeletedObjects to be called once and succeed\n $serviceMock->expects($this->once())\n ->method('syncRemotelyDeletedObjects')\n ->with(\\Jiminny\\Enums\\CrmObject::ACCOUNT);\n\n // Call the protected method using reflection\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');\n $method->setAccessible(true);\n\n // Should not throw any exceptions\n $method->invoke($serviceMock, \\Jiminny\\Enums\\CrmObject::ACCOUNT);\n\n $this->assertTrue(true); // Test completed successfully\n }\n\n public function testSyncRemotelyDeletedObjectsWithErrorHandlingFailure(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['syncRemotelyDeletedObjects'])\n ->getMock();\n\n // Mock team\n $team = $this->createMock(Team::class);\n $team->method('getUuid')->willReturn('team-uuid-456');\n $serviceMock->team = $team;\n\n // Mock logger to verify warning is logged\n $logger = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n\n // Use reflection to set the protected logger property\n $reflection = new \\ReflectionClass($serviceMock);\n $loggerProperty = $reflection->getProperty('logger');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($serviceMock, $logger);\n\n $exception = new \\Exception('Sync failed due to API error');\n\n // Expect syncRemotelyDeletedObjects to throw an exception\n $serviceMock->expects($this->once())\n ->method('syncRemotelyDeletedObjects')\n ->with(\\Jiminny\\Enums\\CrmObject::CONTACT)\n ->willThrowException($exception);\n\n // Expect warning to be logged with correct message and parameters\n $logger->expects($this->once())\n ->method('warning')\n ->with(\n '[Salesforce] Remotely deleted objects sync failed',\n [\n 'objectType' => 'contact',\n 'teamId' => 'team-uuid-456',\n 'reason' => 'Sync failed due to API error',\n ]\n );\n\n // Call the protected method using reflection\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');\n $method->setAccessible(true);\n\n // Should not re-throw the exception, just log it\n $method->invoke($serviceMock, \\Jiminny\\Enums\\CrmObject::CONTACT);\n\n $this->assertTrue(true); // Test completed successfully\n }\n\n public function testSyncRemotelyDeletedObjectsWithErrorHandlingWithLogParams(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['syncRemotelyDeletedObjects'])\n ->getMock();\n\n // Mock team\n $team = $this->createMock(Team::class);\n $team->method('getUuid')->willReturn('team-uuid-789');\n $serviceMock->team = $team;\n\n // Mock logger to verify warning is logged\n $logger = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n\n // Use reflection to set the protected logger property\n $loggerReflection = new \\ReflectionClass($serviceMock);\n $loggerProperty = $loggerReflection->getProperty('logger');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($serviceMock, $logger);\n\n $exception = new \\Exception('Network timeout');\n\n // Expect syncRemotelyDeletedObjects to throw an exception\n $serviceMock->expects($this->once())\n ->method('syncRemotelyDeletedObjects')\n ->with(\\Jiminny\\Enums\\CrmObject::OPPORTUNITY)\n ->willThrowException($exception);\n\n // Additional log parameters\n $logParams = [\n 'syncType' => 'full',\n 'batchSize' => 100,\n ];\n\n // Expect warning to be logged with merged parameters\n $logger->expects($this->once())\n ->method('warning')\n ->with(\n '[Salesforce] Remotely deleted objects sync failed',\n [\n 'objectType' => 'opportunity',\n 'teamId' => 'team-uuid-789',\n 'reason' => 'Network timeout',\n 'syncType' => 'full',\n 'batchSize' => 100,\n ]\n );\n\n // Call the protected method using reflection\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');\n $method->setAccessible(true);\n\n // Should not re-throw the exception, just log it\n $method->invoke($serviceMock, \\Jiminny\\Enums\\CrmObject::OPPORTUNITY, $logParams);\n\n $this->assertTrue(true); // Test completed successfully\n }\n\n /**\n * @dataProvider crmObjectProvider\n */\n public function testSyncRemotelyDeletedObjectsWithErrorHandlingDifferentCrmObjects(\\Jiminny\\Enums\\CrmObject $crmObject): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['syncRemotelyDeletedObjects'])\n ->getMock();\n\n // Mock team\n $team = $this->createMock(Team::class);\n $team->method('getUuid')->willReturn('team-uuid-test');\n $serviceMock->team = $team;\n\n // Mock logger to verify warning is logged\n $logger = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n\n // Use reflection to set the protected logger property\n $loggerReflectionClass = new \\ReflectionClass($serviceMock);\n $loggerProperty = $loggerReflectionClass->getProperty('logger');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($serviceMock, $logger);\n\n $exception = new \\Exception('Test error');\n\n // Expect syncRemotelyDeletedObjects to throw an exception\n $serviceMock->expects($this->once())\n ->method('syncRemotelyDeletedObjects')\n ->with($crmObject)\n ->willThrowException($exception);\n\n // Expect warning to be logged with correct entity type\n $logger->expects($this->once())\n ->method('warning')\n ->with(\n '[Salesforce] Remotely deleted objects sync failed',\n [\n 'objectType' => $crmObject->value,\n 'teamId' => 'team-uuid-test',\n 'reason' => 'Test error',\n ]\n );\n\n // Call the protected method using reflection\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');\n $method->setAccessible(true);\n\n $method->invoke($serviceMock, $crmObject);\n\n $this->assertTrue(true); // Test completed successfully\n }\n\n public function testHandleObjectDeletionWithDeletedEntity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['deleteCrmObject'])\n ->getMock();\n\n $entity = $this->createMock(\\Jiminny\\Models\\Account::class);\n $crmData = ['IsDeleted' => true];\n\n $serviceMock->expects($this->once())\n ->method('deleteCrmObject')\n ->with($entity);\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('handleObjectDeletion');\n $method->setAccessible(true);\n\n $method->invoke($serviceMock, $entity, $crmData);\n }\n\n public function testHandleObjectDeletionWithNonDeletedEntity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['deleteCrmObject'])\n ->getMock();\n\n $entity = $this->createMock(\\Jiminny\\Models\\Contact::class);\n $crmData = ['IsDeleted' => false];\n\n $serviceMock->expects($this->never())\n ->method('deleteCrmObject');\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('handleObjectDeletion');\n $method->setAccessible(true);\n\n $method->invoke($serviceMock, $entity, $crmData);\n }\n\n public function testDeleteCrmObjectWithValidEntity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['dispatchDeleteCrmObjectJob'])\n ->getMock();\n\n $entity = $this->createMock(\\Jiminny\\Models\\Lead::class);\n $entity->expects($this->once())->method('delete');\n\n $serviceMock->expects($this->once())\n ->method('dispatchDeleteCrmObjectJob')\n ->with($entity);\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('deleteCrmObject');\n $method->setAccessible(true);\n\n $method->invoke($serviceMock, $entity);\n }\n\n public function testDeleteCrmObjectWithNullEntity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['dispatchDeleteCrmObjectJob'])\n ->getMock();\n\n $serviceMock->expects($this->never())\n ->method('dispatchDeleteCrmObjectJob');\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('deleteCrmObject');\n $method->setAccessible(true);\n\n $method->invoke($serviceMock, null);\n }\n\n public function testDispatchDeleteCrmObjectJobWithNullEntity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($service);\n $method = $reflection->getMethod('dispatchDeleteCrmObjectJob');\n $method->setAccessible(true);\n\n // Should return early without dispatching - no exception expected\n $method->invoke($service, null);\n\n $this->assertTrue(true); // Test completed successfully\n }\n\n public function testDispatchDeleteCrmObjectJobWithUnsupportedEntity(): void\n {\n $this->expectException(\\TypeError::class);\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n $unsupportedEntity = $this->createMock(\\stdClass::class);\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($service);\n $method = $reflection->getMethod('dispatchDeleteCrmObjectJob');\n\n // This will throw TypeError due to union type constraint\n $method->invoke($service, $unsupportedEntity);\n }\n\n public function testHandleEntityDeletionByProviderIdMethodExists(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n // Test that the method exists and is accessible via reflection\n $reflection = new \\ReflectionClass($service);\n $method = $reflection->getMethod('handleEntityDeletionByProviderId');\n $method->setAccessible(true);\n\n // Verify method exists and has correct parameters\n $this->assertTrue($method->isProtected());\n $this->assertEquals(2, $method->getNumberOfParameters());\n\n $parameters = $method->getParameters();\n $this->assertEquals('targetEntity', $parameters[0]->getName());\n $this->assertEquals('crmData', $parameters[1]->getName());\n }\n\n public function testSyncRemotelyDeletedObjectsWithNoResults(): void\n {\n // Create a real service instance to avoid mock property issues\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n // Mock queryHandler to throw NoResultsException\n $queryHandler = $this->createMock(QueryHandler::class);\n $queryHandler->expects($this->once())\n ->method('queryDeleted')\n ->with('Opportunity')\n ->willThrowException(new NoResultsException('No results'));\n\n // Set the queryHandler using reflection on the real service\n $reflection = new \\ReflectionClass($service);\n $queryHandlerProperty = $reflection->getProperty('queryHandler');\n $queryHandlerProperty->setAccessible(true);\n $queryHandlerProperty->setValue($service, $queryHandler);\n\n $result = self::invokePrivateMethod('syncRemotelyDeletedObjects', $service, [CrmObject::OPPORTUNITY]);\n\n $this->assertFalse($result);\n }\n\n public function testSyncRemotelyDeletedObjectsWithEmptyResults(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n // Mock queryHandler to return empty results\n $queryResult = $this->createMock(QueryResults::class);\n $queryResult->method('getResults')->willReturn([]);\n\n $queryHandler = $this->createMock(QueryHandler::class);\n $queryHandler->expects($this->once())\n ->method('queryDeleted')\n ->with('Opportunity')\n ->willReturn($queryResult);\n\n // Set the queryHandler using reflection on the real service\n $reflection = new \\ReflectionClass($service);\n $queryHandlerProperty = $reflection->getProperty('queryHandler');\n $queryHandlerProperty->setAccessible(true);\n $queryHandlerProperty->setValue($service, $queryHandler);\n\n $result = self::invokePrivateMethod('syncRemotelyDeletedObjects', $service, [CrmObject::OPPORTUNITY]);\n\n $this->assertFalse($result);\n }\n\n public function testSyncRemotelyDeletedObjectsWithUnsupportedCrmObject(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n // Mock queryHandler to return some deleted objects so we reach the match statement\n $deletedObjects = [\n ['id' => 'task1'],\n ['id' => 'task2'],\n ];\n $queryResult = $this->createMock(QueryResults::class);\n $queryResult->method('getResults')->willReturn($deletedObjects);\n\n $queryHandler = $this->createMock(QueryHandler::class);\n $queryHandler->expects($this->once())\n ->method('queryDeleted')\n ->with('Task') // ucfirst('task') = 'Task'\n ->willReturn($queryResult);\n\n self::setPrivateProperty($service, 'queryHandler', $queryHandler);\n\n $this->expectException(InvalidArgumentException::class);\n $this->expectExceptionMessage('Unsupported CrmObject: task');\n\n self::invokePrivateMethod('syncRemotelyDeletedObjects', $service, [CrmObject::TASK]);\n }\n\n public static function crmObjectProvider(): array\n {\n return [\n 'Account' => [CrmObject::ACCOUNT],\n 'Contact' => [CrmObject::CONTACT],\n 'Lead' => [CrmObject::LEAD],\n 'Opportunity' => [CrmObject::OPPORTUNITY],\n ];\n }\n\n public function testVerifyTaskExistsReturnsTrue(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-123', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-123');\n $activity->method('getId')->willReturn(456);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-123', ['Id', 'IsDeleted'])\n ->willReturn(['Id' => 'task-123', 'IsDeleted' => false]);\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertTrue($result);\n }\n\n public function testVerifyTaskExistsReturnsTrueForEvent(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:event-123', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('event-123');\n $activity->method('getId')->willReturn(456);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_EVENT);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Event', 'event-123', ['Id', 'IsDeleted'])\n ->willReturn(['Id' => 'event-123', 'IsDeleted' => false]);\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertTrue($result);\n }\n\n public function testVerifyTaskExistsReturnsFalseWhenDeleted(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-456', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-456');\n $activity->method('getId')->willReturn(789);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-456', ['Id', 'IsDeleted'])\n ->willReturn(['Id' => 'task-456', 'IsDeleted' => true]);\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertFalse($result);\n }\n\n public function testVerifyTaskExistsReturnsFalseWhenNotFound(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-999', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-999');\n $activity->method('getId')->willReturn(999);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-999', ['Id', 'IsDeleted'])\n ->willThrowException(new \\Jiminny\\Exceptions\\HttpNotFoundException('Task not found'));\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertFalse($result);\n }\n\n public function testVerifyTaskExistsReturnsFalseWhenNoPlaybook(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-no-playbook', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-no-playbook');\n $activity->method('getId')->willReturn(111);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn(null);\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertFalse($result);\n }\n\n public function testVerifyTaskExistsThrowsExceptionForTransientErrors(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-error', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-error');\n $activity->method('getId')->willReturn(888);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-error', ['Id', 'IsDeleted'])\n ->willThrowException(new \\RuntimeException('Network timeout'));\n\n $this->expectException(\\RuntimeException::class);\n $this->expectExceptionMessage('Network timeout');\n\n $service->verifyTaskExists($activity);\n }\n\n public function testVerifyTaskExistsCachesResults(): void\n {\n $cachedValue = null;\n Cache::shouldReceive('remember')\n ->twice()\n ->with('crm_task_exists:123:task-cached', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(function ($key, $ttl, $callback) use (&$cachedValue) {\n if ($cachedValue === null) {\n $cachedValue = $callback();\n }\n\n return $cachedValue;\n });\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-cached');\n $activity->method('getId')->willReturn(555);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-cached', ['Id', 'IsDeleted'])\n ->willReturn(['Id' => 'task-cached', 'IsDeleted' => false]);\n\n $result1 = $service->verifyTaskExists($activity);\n $result2 = $service->verifyTaskExists($activity);\n\n $this->assertTrue($result1);\n $this->assertTrue($result2);\n }\n\n public function testVerifyTaskExistsReturnsFalseForHttpBadRequestException(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-400', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-400');\n $activity->method('getId')->willReturn(400);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-400', ['Id', 'IsDeleted'])\n ->willThrowException(new \\Jiminny\\Exceptions\\HttpBadRequestException('Bad request'));\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertFalse($result);\n }\n\n public function testImportOpportunitySkipsWhenNoProfileAndNoAccount(): void\n {\n $crmData = [\n 'Id' => 'SF-NO-USER-1',\n 'Name' => 'Test Opportunity',\n 'OwnerId' => 'owner-no-profile',\n // No AccountId\n ];\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(\\Illuminate\\Events\\Dispatcher::class); // ← ADD THIS\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n\n $service = new Service(\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService\n );\n\n $config = $this->createMock(Configuration::class);\n\n // Mock profiles relation returning null (no profile found)\n $profilesRelation = \\Mockery::mock(\\Illuminate\\Database\\Eloquent\\Relations\\HasMany::class);\n $profilesRelation->shouldReceive('where')->with('crm_provider_id', 'owner-no-profile')->andReturnSelf();\n $profilesRelation->shouldReceive('first')->andReturn(null);\n\n $config->expects($this->once())\n ->method('profiles')\n ->willReturn($profilesRelation);\n\n $team = $this->createMock(Team::class);\n $team->method('getId')->willReturn(1);\n\n $logger = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n $logger->expects($this->once())\n ->method('error')\n ->with(\n '[Salesforce] | Skip import, no user_id found',\n ['id' => 'SF-NO-USER-1']\n );\n\n $reflection = new \\ReflectionClass($service);\n\n $configProperty = $reflection->getProperty('config');\n $configProperty->setAccessible(true);\n $configProperty->setValue($service, $config);\n\n $teamProperty = $reflection->getProperty('team');\n $teamProperty->setAccessible(true);\n $teamProperty->setValue($service, $team);\n\n $loggerProperty = $reflection->getProperty('logger');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($service, $logger);\n\n // Initialize profile property to avoid \"must not be accessed before initialization\" error\n $profileProperty = $reflection->getProperty('profile');\n $profileProperty->setAccessible(true);\n $profileProperty->setValue($service, null);\n\n $result = self::invokePrivateMethod('importOpportunity', $service, [$crmData]);\n\n $this->assertNull($result);\n }\n\n public function testImportOpportunitySkipsWhenProfileNotFound(): void\n {\n $crmData = [\n 'Id' => 'SF-NO-USER-2',\n 'Name' => 'Test Opportunity',\n 'OwnerId' => 'owner-not-found',\n // No AccountId - avoid complex account processing\n ];\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $eventDispatcher = $this->createMock(\\Illuminate\\Events\\Dispatcher::class); // ← ADD THIS\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n\n\n $service = new Service(\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService\n );\n\n $config = $this->createMock(Configuration::class);\n\n // Mock profiles relation returning null (no profile found)\n $profilesRelation = \\Mockery::mock(\\Illuminate\\Database\\Eloquent\\Relations\\HasMany::class);\n $profilesRelation->shouldReceive('where')->with('crm_provider_id', 'owner-not-found')->andReturnSelf();\n $profilesRelation->shouldReceive('first')->andReturn(null);\n\n $config->expects($this->once())\n ->method('profiles')\n ->willReturn($profilesRelation);\n\n $team = $this->createMock(Team::class);\n $team->method('getId')->willReturn(1);\n\n $logger = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n $logger->expects($this->once())\n ->method('error')\n ->with(\n '[Salesforce] | Skip import, no user_id found',\n ['id' => 'SF-NO-USER-2']\n );\n\n $reflection = new \\ReflectionClass($service);\n\n $configProperty = $reflection->getProperty('config');\n $configProperty->setAccessible(true);\n $configProperty->setValue($service, $config);\n\n $teamProperty = $reflection->getProperty('team');\n $teamProperty->setAccessible(true);\n $teamProperty->setValue($service, $team);\n\n $loggerProperty = $reflection->getProperty('logger');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($service, $logger);\n\n // Initialize profile property\n $profileProperty = $reflection->getProperty('profile');\n $profileProperty->setAccessible(true);\n $profileProperty->setValue($service, null);\n\n $result = self::invokePrivateMethod('importOpportunity', $service, [$crmData]);\n\n $this->assertNull($result);\n }\n}\n\nclass HasManyExtended extends HasMany\n{\n public function where()\n {\n }\n\n public function first()\n {\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Services\\Crm\\Salesforce;\n\nuse Illuminate\\Database\\Eloquent\\Relations\\HasMany;\nuse Illuminate\\Events\\Dispatcher;\nuse Illuminate\\Support\\Facades\\Cache;\nuse Jiminny\\Component\\Country\\CountriesMap;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Enums\\CrmObject;\nuse Jiminny\\Exceptions\\InvalidArgumentException;\nuse Jiminny\\Exceptions\\NoResultsException;\nuse Jiminny\\Integrations\\PlaybookResolver;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Crm\\Configuration;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\TeamSettings;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\ContactRoleRepository;\nuse Jiminny\\Repositories\\Crm\\FieldDataRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\Salesforce\\Client;\nuse Jiminny\\Services\\Crm\\Salesforce\\PayloadBuilder;\nuse Jiminny\\Services\\Crm\\Salesforce\\QueryBuilder;\nuse Jiminny\\Services\\Crm\\Salesforce\\QueryHandler;\nuse Jiminny\\Services\\Crm\\Salesforce\\QueryIterator;\nuse Jiminny\\Services\\Crm\\Salesforce\\QueryResults;\nuse Jiminny\\Services\\Crm\\Salesforce\\Service;\nuse PHPUnit\\Framework\\MockObject\\MockObject;\nuse Tests\\TestCase;\nuse Tests\\Unit\\Traits\\TestPrivateMethod;\n\nclass ServiceTest extends TestCase\n{\n use TestPrivateMethod;\n\n public function testFetchAndAssociateRelatedActivity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $payloadBuilder->method('addCustomLogicFieldsPayload')\n ->willReturnCallback(function ($activity, $payload) {\n return $payload;\n });\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods(['fetchRelatedActivity', 'getPlaybook', 'getPlaybookCategory', 'updateRecord'])\n ->getMock();\n\n $serviceMock->expects($this->once())\n ->method('fetchRelatedActivity')\n ->willReturn([\n 'Id' => 'testId',\n 'Type' => null,\n 'OwnerId' => 'testerUser',\n 'Description' => 'Test description',\n ]);\n\n $user = $this->createMock(User::class);\n $team = $this->createMock(Team::class);\n $user->method('getAttribute')->with('team')->willReturn($team);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityField')->willReturn(null);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_EVENT);\n\n $serviceMock->expects($this->once())\n ->method('getPlaybook')\n ->with($user)\n ->willReturn($playbook);\n\n $serviceMock->expects($this->never())\n ->method('getPlaybookCategory');\n\n $serviceMock->expects($this->never())\n ->method('updateRecord');\n\n $fieldDataRepository = $this->createMock(FieldDataRepository::class);\n $fieldDataRepository->method('getActivityFieldData')->willReturn(collect([]));\n app()->instance(FieldDataRepository::class, $fieldDataRepository);\n\n $config = $this->createMock(Configuration::class);\n $profilesRelation = $this->getMockBuilder(\\Illuminate\\Database\\Eloquent\\Relations\\HasMany::class)\n ->disableOriginalConstructor()\n ->onlyMethods(['get'])\n ->addMethods(['where'])\n ->getMock();\n $profilesRelation->method('where')->willReturnSelf();\n $profilesRelation->method('get')->willReturn(collect([]));\n $config->method('profiles')->willReturn($profilesRelation);\n\n $serviceMock->config = $config;\n $serviceMock->profile = null;\n\n $actualStartTime = \\Carbon\\Carbon::now();\n\n $activity = $this->getMockBuilder(Activity::class)\n ->disableOriginalConstructor()\n ->onlyMethods(['update', 'hasProspect'])\n ->getMock();\n\n $activity->method('update')->willReturn(true);\n $activity->method('hasProspect')->willReturn(true);\n\n $activity->type = Activity::TYPE_CONFERENCE;\n $activity->provider = Activity::PROVIDER_TWILIO;\n $activity->lead_id = 1;\n $activity->user_id = 0;\n $activity->id_string = 'test-activity-id';\n $activity->user = $user;\n\n $activity->actual_start_time = $actualStartTime;\n $activity->uuid = 'c53d8320-f556-4cee-a2f8-5f232f454ca4';\n\n app()->bind(PlaybookResolver::class, function () use ($user) {\n $playbook = $this->createMock(Playbook::class);\n $playbookResolver = $this->createMock(PlaybookResolver::class);\n $playbookResolver->expects($this->once())\n ->method('resolvePlaybookByUser')\n ->with($user)\n ->willReturn($playbook);\n\n return $playbookResolver;\n });\n\n $data = $serviceMock->fetchAndAssociateRelatedActivity($activity);\n\n $this->assertInstanceOf(Activity::class, $data);\n $this->assertEquals(Activity::TYPE_CONFERENCE, $data->getType());\n $this->assertEquals($actualStartTime->getTimestamp(), $data->getActualStartTime()->getTimestamp());\n }\n\n public function testFetchAndAssociateRelatedActivitySkipsForTaskBasedPlaybook(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods(['fetchRelatedActivity', 'getPlaybook'])\n ->getMock();\n\n $user = $this->createMock(User::class);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n $playbook->method('getId')->willReturn(123);\n\n $serviceMock->expects($this->once())\n ->method('getPlaybook')\n ->with($user)\n ->willReturn($playbook);\n\n $serviceMock->expects($this->never())\n ->method('fetchRelatedActivity');\n\n $activity = $this->getMockBuilder(Activity::class)\n ->disableOriginalConstructor()\n ->onlyMethods(['hasProspect', 'getUuid'])\n ->getMock();\n\n $activity->method('hasProspect')->willReturn(true);\n $activity->method('getUuid')->willReturn('c53d8320-f556-4cee-a2f8-5f232f454ca4');\n $activity->type = Activity::TYPE_CONFERENCE;\n $activity->actual_start_time = \\Carbon\\Carbon::now();\n $activity->user = $user;\n\n $result = $serviceMock->fetchAndAssociateRelatedActivity($activity);\n\n $this->assertNull($result);\n }\n\n public function testFetchAndAssociateRelatedActivityReturnsNullForNonConference(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $serviceMock = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)\n );\n\n $activity = $this->getMockBuilder(Activity::class)\n ->disableOriginalConstructor()\n ->getMock();\n\n $activity->type = Activity::TYPE_SOFTPHONE;\n\n $result = $serviceMock->fetchAndAssociateRelatedActivity($activity);\n\n $this->assertNull($result);\n }\n\n public function testFetchAndAssociateRelatedActivityReturnsNullWhenNoStartTime(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $serviceMock = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)\n );\n\n\n $activity = $this->getMockBuilder(Activity::class)\n ->disableOriginalConstructor()\n ->getMock();\n\n $activity->type = Activity::TYPE_CONFERENCE;\n $activity->actual_start_time = null;\n $activity->scheduled_start_time = null;\n\n $result = $serviceMock->fetchAndAssociateRelatedActivity($activity);\n\n $this->assertNull($result);\n }\n\n public function testFetchAndAssociateRelatedActivityReturnsNullWhenNoProspect(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods(['getPlaybook'])\n ->getMock();\n\n $serviceMock->expects($this->never())\n ->method('getPlaybook');\n\n $activity = $this->getMockBuilder(Activity::class)\n ->disableOriginalConstructor()\n ->onlyMethods(['hasProspect', 'getUuid'])\n ->getMock();\n\n $activity->method('hasProspect')->willReturn(false);\n $activity->method('getUuid')->willReturn('c53d8320-f556-4cee-a2f8-5f232f454ca4');\n $activity->type = Activity::TYPE_CONFERENCE;\n $activity->actual_start_time = \\Carbon\\Carbon::now();\n\n $result = $serviceMock->fetchAndAssociateRelatedActivity($activity);\n\n $this->assertNull($result);\n }\n\n public function testMatchExactlyByEmail(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods([])\n ->getMock();\n\n $profile = new Profile();\n $profile->setAttribute('id', bin2hex(random_bytes(8)));\n $serviceMock->profile = $profile;\n\n $team = $this->createMock(Team::class);\n $serviceMock->team = $team;\n\n $data = $serviceMock->matchExactlyByEmail(bin2hex(random_bytes(8)) . 'test_email@testserver.com');\n\n $this->assertEquals(null, $data);\n }\n\n public function testMatchDomainFromEmail(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $queryIterator = $this->createMock(QueryIterator::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $config = $this->createMock(Configuration::class);\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('search')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods(['convertCrmData'])\n ->getMock();\n\n $profile = new Profile();\n $profile->account_fields = 'Field1, Field2, Field3';\n $serviceMock->profile = $profile;\n\n $serviceMock->expects($this->once())\n ->method('convertCrmData')\n ->willReturn(['test']);\n\n $this->app->bind(QueryBuilder::class, function () {\n $queryBuilder = $this->createMock(QueryBuilder::class);\n $queryBuilder->expects($this->once())\n ->method('buildMatchByDomainQuery')\n ->with('test_email@testserver.com')\n ->willReturn('FIND {test_email@testserver.com} IN ALL FIELDS RETURNING Account(Id)');\n\n return $queryBuilder;\n });\n\n $team = $this->createMock(Team::class);\n\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n\n $data = $serviceMock->matchByDomain('test_email@testserver.com');\n\n $this->assertEquals(['test'], $data);\n }\n\n public function testBuildTaskSearchFields(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)\n );\n\n $fields = $service->buildTaskSearchFields();\n\n $expectedFields = ['Id', 'WhoId', 'WhatId', 'AccountId'];\n\n $this->assertEquals($expectedFields, $fields);\n }\n\n public function testMapCrmObjects(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class),\n );\n\n $sampleTask = [\n 'WhoId' => '003sampleWhoId',\n 'AccountId' => 'sampleAccountId',\n 'WhatId' => 'sampleWhatId',\n ];\n\n $activityData = $service->mapCrmObjects($sampleTask);\n\n $expectedActivityData = [\n 'contact' => '003sampleWhoId',\n 'account' => 'sampleAccountId',\n 'opportunity' => 'sampleWhatId',\n ];\n\n $this->assertEquals($expectedActivityData, $activityData);\n }\n\n public function testGetInstalledAppVersion(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n $queryIterator = $this->createMock(QueryIterator::class);\n $queryIterator->expects($this->any())\n ->method('current')->willReturn([\n 'SubscriberPackageVersion' => [\n 'MajorVersion' => '1',\n 'MinorVersion' => '0',\n 'PatchVersion' => '1',\n 'BuildNumber' => '0',\n ],\n ]);\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('metadata')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods(array_diff(get_class_methods(Service::class), ['getInstalledAppVersion']))\n ->getMock();\n\n $version = $serviceMock->getInstalledAppVersion();\n\n $this->assertEquals('1010', $version);\n }\n\n public function testSyncProfiles(): void\n {\n $userToSearch = null;\n\n $team = $this->createMock(Team::class);\n $config = $this->createMockedConfiguration();\n $config->expects($this->once())\n ->method('getId')\n ->willReturn(1);\n\n $salesforceUser = [\n 'Email' => 'test@example.com',\n 'UserPreferencesLightningExperiencePreferred' => true,\n 'CallCenterId' => '123',\n 'Id' => '456',\n 'ProfileId' => '789',\n ];\n\n app()->bind(QueryBuilder::class, function () use ($userToSearch) {\n $queryBuilder = $this->createMock(QueryBuilder::class);\n $queryBuilder->expects($this->once())\n ->method('buildGetUsersQuery')\n ->with($userToSearch)\n ->willReturn('SELECT * FROM Users');\n\n return $queryBuilder;\n });\n\n $queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('query')\n ->with('SELECT * FROM Users')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n\n $user = $this->createMock(User::class);\n $user->expects($this->once())\n ->method('getId')\n ->willReturn(123);\n\n $this->mockTeamRepository($team, $salesforceUser, $user);\n\n $profileRepository = $this->createMock(ProfileRepository::class);\n $profileRepository->expects($this->once())\n ->method('updateOrCreateProfile')\n ->with(\n $user,\n [\n 'crm_configuration_id' => 1,\n 'crm_provider_id' => '456',\n ],\n [\n 'user_id' => 123,\n 'edition' => Profile::EDITION_LIGHTNING,\n 'has_external_cti' => true,\n 'crm_profile_id' => '789',\n ]\n )\n ->willReturn(new Profile());\n\n $this->app->instance(ProfileRepository::class, $profileRepository);\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n $result = $serviceMock->syncProfiles($userToSearch);\n\n $this->assertNull($result);\n }\n\n public function testSyncProfilesEmailIsNull(): void\n {\n $userToSearch = $this->createMock(User::class);\n\n $salesforceUser = [\n 'Email' => null,\n ];\n\n app()->bind(QueryBuilder::class, function () {\n $queryBuilder = $this->createMock(QueryBuilder::class);\n $queryBuilder->expects($this->once())\n ->method('buildGetUsersQuery')\n ->with(null)\n ->willReturn('SELECT * FROM Users');\n\n return $queryBuilder;\n });\n\n $queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->exactly(2))\n ->method('query')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n\n $team = $this->createMock(Team::class);\n $user = $this->createMock(User::class);\n $this->mockTeamRepository($team, $salesforceUser, $user, false);\n\n $config = $this->createMock(Configuration::class);\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n\n $profile = $serviceMock->syncProfiles(null);\n\n $this->assertNull($profile);\n }\n\n public function testSyncProfilesUserToSearchMatchesCurrentUser(): void\n {\n $userToSearch = $this->createMock(User::class);\n $userToSearch->expects($this->once())\n ->method('getId')\n ->willReturn(123);\n\n $team = $this->createMock(Team::class);\n $config = $this->createMock(Configuration::class);\n $config->expects($this->once())\n ->method('getId')\n ->willReturn(1);\n\n $salesforceUser = [\n 'Email' => 'test@example.com',\n 'UserPreferencesLightningExperiencePreferred' => true,\n 'CallCenterId' => '123',\n 'Id' => '456',\n 'ProfileId' => '789',\n ];\n\n app()->bind(QueryBuilder::class, function () use ($userToSearch) {\n $queryBuilder = $this->createMock(QueryBuilder::class);\n $queryBuilder->expects($this->once())\n ->method('buildGetUsersQuery')\n ->with($userToSearch)\n ->willReturn('SELECT * FROM Users');\n\n return $queryBuilder;\n });\n\n $queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('query')\n ->with('SELECT * FROM Users')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n\n $user = $this->createMock(User::class);\n $user->expects($this->exactly(2))\n ->method('getId')\n ->willReturn(123);\n\n $this->mockTeamRepository($team, $salesforceUser, $user);\n\n $profileRepository = $this->createMock(ProfileRepository::class);\n $profileRepository->expects($this->once())\n ->method('updateOrCreateProfile')\n ->with(\n $user,\n [\n 'crm_configuration_id' => 1,\n 'crm_provider_id' => '456',\n ],\n [\n 'user_id' => 123,\n 'edition' => Profile::EDITION_LIGHTNING,\n 'has_external_cti' => true,\n 'crm_profile_id' => '789',\n ]\n )\n ->willReturn(new Profile());\n\n $this->app->instance(ProfileRepository::class, $profileRepository);\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n $profile = $serviceMock->syncProfiles($userToSearch);\n\n $this->assertInstanceOf(Profile::class, $profile);\n }\n\n public function testSyncProfilesWithCustomValidation(): void\n {\n $userToSearch = null;\n\n $team = $this->createMock(Team::class);\n $config = $this->createMockedConfiguration();\n $config->expects($this->atLeastOnce()) // Changed from once() to atLeastOnce()\n ->method('getId')\n ->willReturn(1);\n\n $salesforceUser = [\n 'Email' => 'test@example.com',\n 'UserPreferencesLightningExperiencePreferred' => true,\n 'CallCenterId' => '123',\n 'Id' => '456',\n 'ProfileId' => '789',\n 'CustomField' => 'CustomValue',\n ];\n\n $customRules = [\n ['field' => 'CustomField', 'value' => 'CustomValue'],\n ];\n\n $this->mockQueryBuilderAndHandler($userToSearch, [$salesforceUser]);\n\n $user = $this->createMock(User::class);\n $user->expects($this->once())\n ->method('getId')\n ->willReturn(123);\n\n $this->mockTeamRepository($team, $salesforceUser, $user, true, $customRules);\n\n $this->mockProfileRepository($user);\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n $profile = $serviceMock->syncProfiles($userToSearch);\n\n $this->assertNull($profile);\n }\n\n public function testSyncProfilesWithCustomValidationFailing(): void\n {\n $userToSearch = null;\n\n $team = $this->createMock(Team::class);\n $config = $this->createMockedConfiguration();\n\n $salesforceUser = [\n 'Email' => 'test@example.com',\n 'UserPreferencesLightningExperiencePreferred' => true,\n 'CallCenterId' => '123',\n 'Id' => '456',\n 'ProfileId' => '789',\n 'CustomField' => 'WrongValue',\n ];\n\n $customRules = [\n ['field' => 'CustomField', 'value' => 'CustomValue'],\n ];\n\n $this->mockQueryBuilderAndHandler($userToSearch, [$salesforceUser]);\n\n $teamRepository = $this->getMockForAbstractClass(TeamRepository::class, [], '', false, true, true, ['findActiveTeamMemberByEmail', 'getTeamSetting']);\n\n $teamSettings = $this->createMock(TeamSettings::class);\n $teamSettings->method('getValueType')\n ->willReturn('array');\n\n $teamSettings->method('getValue')\n ->willReturn(json_encode($customRules));\n\n $teamRepository->expects($this->once())\n ->method('getTeamSetting')\n ->with($team, 'custom_profile_validation')\n ->willReturn($teamSettings);\n\n app()->bind(TeamRepository::class, function () use ($teamRepository) {\n return $teamRepository;\n });\n\n $profileRepository = $this->createMock(ProfileRepository::class);\n $profileRepository->expects($this->never())\n ->method('updateOrCreateProfile');\n\n $this->app->instance(ProfileRepository::class, $profileRepository);\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n $result = $serviceMock->syncProfiles($userToSearch);\n\n $this->assertNull($result);\n }\n\n public function testGetContactRolesFromCrm(): void\n {\n $contactRoles = [\n [\n 'Id' => '1',\n 'ContactId' => 'Contact1',\n 'OpportunityId' => 'Opportunity1',\n 'Opportunity' => ['OwnerId' => 'Owner1'],\n 'IsPrimary' => true,\n 'Role' => 'Decision Maker',\n ],\n ];\n\n $expectedResponse = [\n [\n 'id' => '1',\n 'contactId' => 'Contact1',\n 'opportunityId' => 'Opportunity1',\n 'ownerId' => 'Owner1',\n 'isPrimary' => true,\n 'role' => 'Decision Maker',\n ],\n ];\n\n $this->bindQueryIterator($contactRoles);\n\n $serviceMock = $this->getServiceMock();\n\n $data = $serviceMock->getContactRolesFromCrm(now()->subDay());\n\n $this->assertEquals($expectedResponse, $data);\n }\n\n public function testGetContactRolesFromCrmNoResult(): void\n {\n $this->bindQueryIterator([]);\n\n $serviceMock = $this->getServiceMock();\n\n $data = $serviceMock->getContactRolesFromCrm(now()->subDay());\n\n $this->assertEquals([], $data);\n }\n\n public function testSyncContactRoles(): void\n {\n $contactRoles = [\n [\n 'id' => '1',\n 'contactId' => 'Contact1',\n 'opportunityId' => 'Opportunity1',\n 'ownerId' => 'Owner1',\n 'isPrimary' => true,\n 'role' => 'Decision Maker',\n ],\n ];\n\n app()->bind(ContactRoleRepository::class, function () {\n $contactRoleRepository = $this->createMock(ContactRoleRepository::class);\n $contactRoleRepository->expects($this->once())\n ->method('saveContactRoles');\n\n return $contactRoleRepository;\n });\n\n $serviceMock = $this->getServiceMock([\n 'getContactRolesFromCrm',\n 'syncRemotelyDeletedContactRoles',\n 'syncContact',\n 'syncOpportunity',\n ]);\n\n $config = $this->createMock(Configuration::class);\n $hasMany = $this->createMock(HasManyExtended::class);\n $hasMany->expects($this->exactly(2))\n ->method('where')\n ->willReturn($hasMany);\n\n $hasMany->expects($this->exactly(2))\n ->method('first')\n ->willReturn(\n $this->createMock(Contact::class),\n $this->createMock(Opportunity::class)\n );\n\n $config->expects($this->once())\n ->method('contacts')\n ->willReturn($hasMany);\n $config->expects($this->once())\n ->method('opportunities')\n ->willReturn($hasMany);\n\n $serviceMock->config = $config;\n\n $serviceMock->expects($this->once())\n ->method('getContactRolesFromCrm')\n ->willReturn($contactRoles);\n\n $serviceMock->expects($this->once())\n ->method('syncRemotelyDeletedContactRoles');\n\n $data = $serviceMock->syncContactRoles(now()->subDay());\n\n $this->assertEquals(1, $data);\n }\n\n public function testSyncRemotelyDeletedContactRoles(): void\n {\n $contactRoles = [\n [\n 'id' => '1',\n 'crm_provider_id' => '1',\n ],\n ];\n\n app()->bind(QueryHandler::class, function () use ($contactRoles) {\n $queryResults = new QueryResults($contactRoles, 1, true, null);\n\n $handler = $this->createMock(QueryHandler::class);\n $handler->method('queryDeleted')\n ->willReturn($queryResults);\n\n return $handler;\n });\n\n app()->bind(ContactRoleRepository::class, function () {\n $contactRoleRepository = $this->createMock(ContactRoleRepository::class);\n $contactRoleRepository->expects($this->once())\n ->method('deleteContactRoles');\n\n return $contactRoleRepository;\n });\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $this->createMock(Team::class);\n\n $data = $this->invokePrivateMethod('syncRemotelyDeletedContactRoles', $serviceMock, []);\n\n $this->assertTrue($data);\n }\n\n private function bindQueryIterator(array $queryResult): void\n {\n /** @var Client $client */\n $client = $this->createMock(Client::class);\n $queryIterator = new QueryIterator(\n $client,\n new QueryResults($queryResult, 1, true, null)\n );\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('query')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n }\n\n public static function getOpportunitySortOrderDataProvider(): array\n {\n return [\n 'all open recently updated' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED, ['LastModifiedDate DESC', true]],\n 'all open recently created' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED, ['CreatedDate DESC', true]],\n 'all open oldest created' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED, ['CreatedDate ASC', true]],\n 'all recently updated' => [Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED, ['LastModifiedDate DESC', false]],\n 'default' => ['unknown', ['LastModifiedDate DESC', true]],\n ];\n }\n\n private function createMockedConfiguration(): Configuration\n {\n $config = $this->createMock(Configuration::class);\n $profilesRelation = $this->getMockBuilder(\\Illuminate\\Database\\Eloquent\\Relations\\HasMany::class)\n ->disableOriginalConstructor()\n ->onlyMethods(['get'])\n ->addMethods(['where', 'first'])\n ->getMock();\n $profilesRelation->method('where')->willReturnSelf();\n $profilesRelation->method('get')->willReturn(collect([]));\n $profilesRelation->method('first')->willReturn(null);\n $config->method('profiles')->willReturn($profilesRelation);\n\n return $config;\n }\n\n private function getServiceMock(array $onlyMethods = []): MockObject&Service\n {\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $this->createMock(Client::class),\n $this->createMock(PayloadBuilder::class),\n $this->createMock(Dispatcher::class),\n $this->createMock(CountriesMap::class),\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods($onlyMethods)\n ->getMock();\n\n $serviceMock->profile = $this->createMock(Profile::class);\n\n return $serviceMock;\n }\n\n private function mockQueryBuilderAndHandler($userToSearch, $salesforceUsers): void\n {\n app()->bind(QueryBuilder::class, function () use ($userToSearch) {\n $queryBuilder = $this->createMock(QueryBuilder::class);\n $queryBuilder->expects($this->once())\n ->method('buildGetUsersQuery')\n ->with($userToSearch)\n ->willReturn('SELECT * FROM Users');\n\n return $queryBuilder;\n });\n\n $queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults($salesforceUsers, count($salesforceUsers), true, null));\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('query')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n }\n\n private function mockTeamRepository(\n Team $team,\n array $salesforceUser,\n ?User $user = null,\n bool $userSearch = true,\n array $customRules = []\n ): void {\n $teamRepository = $this->getMockForAbstractClass(TeamRepository::class, [], '', false, true, true, ['findActiveTeamMemberByEmail', 'getTeamSetting']);\n\n if ($userSearch) {\n $teamRepository->expects($this->once())\n ->method('findActiveTeamMemberByEmail')\n ->with($team, $salesforceUser['Email'])\n ->willReturn($user);\n }\n\n $teamSettings = $this->createMock(TeamSettings::class);\n $teamSettings->method('getValueType')\n ->willReturn('array');\n\n $teamSettings->method('getValue')\n ->willReturn(json_encode($customRules));\n\n $teamRepository->expects($this->once())\n ->method('getTeamSetting')\n ->with($team, 'custom_profile_validation')\n ->willReturn($teamSettings);\n\n app()->bind(TeamRepository::class, function () use ($teamRepository) {\n return $teamRepository;\n });\n }\n\n private function mockProfileRepository(User $user): void\n {\n $profileRepository = $this->createMock(ProfileRepository::class);\n $profileRepository->expects($this->once())\n ->method('updateOrCreateProfile')\n ->with(\n $user,\n [\n 'crm_configuration_id' => 1,\n 'crm_provider_id' => '456',\n ],\n [\n 'user_id' => 123,\n 'edition' => Profile::EDITION_LIGHTNING,\n 'has_external_cti' => true,\n 'crm_profile_id' => '789',\n ]\n )\n ->willReturn(new Profile());\n\n $this->app->instance(ProfileRepository::class, $profileRepository);\n }\n\n public function testBuildEnhancedNoteDecodesHtmlEntities(): void\n {\n $service = $this->getServiceMock(['createRecord']);\n\n $profile = new Profile();\n $profile->setAttribute('crm_provider_id', 'owner-123');\n $service->profile = $profile;\n\n $service->expects($this->exactly(2))\n ->method('createRecord')\n ->willReturnOnConsecutiveCalls('note-id-123', 'link-id-456');\n\n $bodyWithEntities = 'Welch's current challenges and Facebook's Club';\n\n $result = $this->invokePrivateMethod('buildEnhancedNote', $service, [\n 'Test Title',\n $bodyWithEntities,\n 'object-id-789',\n ]);\n\n $this->assertEquals('note-id-123', $result);\n }\n\n public function testBuildEnhancedNoteSanitizesWithoutQuotes(): void\n {\n $service = $this->getServiceMock(['createRecord']);\n\n $profile = new Profile();\n $profile->setAttribute('crm_provider_id', 'owner-456');\n $service->profile = $profile;\n\n $service->expects($this->exactly(2))\n ->method('createRecord')\n ->willReturnCallback(function ($type, $data) {\n if ($type === 'ContentNote') {\n $decoded = base64_decode($data['Content']);\n $this->assertStringContainsString(\"Welch's\", $decoded);\n $this->assertStringNotContainsString(''', $decoded);\n $this->assertStringNotContainsString('&#039;', $decoded);\n $this->assertStringContainsString('<script>', $decoded);\n\n return 'note-id-456';\n }\n\n return 'link-id-789';\n });\n\n $bodyWithMixedContent = \"Welch's and <script>alert('xss')</script>\";\n\n $result = $this->invokePrivateMethod('buildEnhancedNote', $service, [\n 'Test Title',\n $bodyWithMixedContent,\n 'object-id-123',\n ]);\n\n $this->assertEquals('note-id-456', $result);\n }\n\n public function testBuildEnhancedNoteConvertsLineBreaks(): void\n {\n $service = $this->getServiceMock(['createRecord']);\n\n $profile = new Profile();\n $profile->setAttribute('crm_provider_id', 'owner-789');\n $service->profile = $profile;\n\n $service->expects($this->exactly(2))\n ->method('createRecord')\n ->willReturnCallback(function ($type, $data) {\n if ($type === 'ContentNote') {\n $decoded = base64_decode($data['Content']);\n $this->assertStringContainsString('<br>', $decoded);\n $this->assertStringNotContainsString('<br />', $decoded);\n\n return 'note-id-789';\n }\n\n return 'link-id-012';\n });\n\n $bodyWithLineBreaks = \"Line 1\\nLine 2\\nLine 3\";\n\n $result = $this->invokePrivateMethod('buildEnhancedNote', $service, [\n 'Test Title',\n $bodyWithLineBreaks,\n 'object-id-456',\n ]);\n\n $this->assertEquals('note-id-789', $result);\n }\n\n public function testBuildEnhancedNoteHandlesComplexScenario(): void\n {\n $service = $this->getServiceMock(['createRecord']);\n\n $profile = new Profile();\n $profile->setAttribute('crm_provider_id', 'owner-complex');\n $service->profile = $profile;\n\n $service->expects($this->exactly(2))\n ->method('createRecord')\n ->willReturnCallback(function ($type, $data) {\n if ($type === 'ContentNote') {\n $decoded = base64_decode($data['Content']);\n\n $this->assertStringContainsString(\"Welch's\", $decoded);\n $this->assertStringContainsString(\"Facebook's Club\", $decoded);\n $this->assertStringContainsString(\"Arctics'\", $decoded);\n $this->assertStringNotContainsString(''', $decoded);\n $this->assertStringNotContainsString('&#039;', $decoded);\n $this->assertStringContainsString('<br>', $decoded);\n $this->assertStringContainsString('<', $decoded);\n $this->assertStringContainsString('>', $decoded);\n\n return 'note-complex';\n }\n\n return 'link-complex';\n });\n\n $complexBody = \"Summary:\\n---------\\nThe call focused on understanding Welch's current challenges and exploring how Arctics' Revenue Growth Management solutions could support their strategic goals.\\n\\n• John SMith discussed his role as a category advisor for Google and Facebook's Club, emphasizing the importance of market research and advising on product assortment.\\n• Madona introduced Arctics' Virtual Shoppers AI, which simulates consumer <behavior> to optimize pricing and promotional strategies.\";\n\n $result = $this->invokePrivateMethod('buildEnhancedNote', $service, [\n 'Jiminny Transcription Summary',\n $complexBody,\n 'task-id-001',\n ]);\n\n $this->assertEquals('note-complex', $result);\n }\n\n public function testSyncRemotelyDeletedObjectsWithErrorHandlingSuccess(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['syncRemotelyDeletedObjects'])\n ->getMock();\n\n // Mock team\n $team = $this->createMock(Team::class);\n $team->method('getUuid')->willReturn('team-uuid-123');\n $serviceMock->team = $team;\n\n // Expect syncRemotelyDeletedObjects to be called once and succeed\n $serviceMock->expects($this->once())\n ->method('syncRemotelyDeletedObjects')\n ->with(\\Jiminny\\Enums\\CrmObject::ACCOUNT);\n\n // Call the protected method using reflection\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');\n $method->setAccessible(true);\n\n // Should not throw any exceptions\n $method->invoke($serviceMock, \\Jiminny\\Enums\\CrmObject::ACCOUNT);\n\n $this->assertTrue(true); // Test completed successfully\n }\n\n public function testSyncRemotelyDeletedObjectsWithErrorHandlingFailure(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['syncRemotelyDeletedObjects'])\n ->getMock();\n\n // Mock team\n $team = $this->createMock(Team::class);\n $team->method('getUuid')->willReturn('team-uuid-456');\n $serviceMock->team = $team;\n\n // Mock logger to verify warning is logged\n $logger = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n\n // Use reflection to set the protected logger property\n $reflection = new \\ReflectionClass($serviceMock);\n $loggerProperty = $reflection->getProperty('logger');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($serviceMock, $logger);\n\n $exception = new \\Exception('Sync failed due to API error');\n\n // Expect syncRemotelyDeletedObjects to throw an exception\n $serviceMock->expects($this->once())\n ->method('syncRemotelyDeletedObjects')\n ->with(\\Jiminny\\Enums\\CrmObject::CONTACT)\n ->willThrowException($exception);\n\n // Expect warning to be logged with correct message and parameters\n $logger->expects($this->once())\n ->method('warning')\n ->with(\n '[Salesforce] Remotely deleted objects sync failed',\n [\n 'objectType' => 'contact',\n 'teamId' => 'team-uuid-456',\n 'reason' => 'Sync failed due to API error',\n ]\n );\n\n // Call the protected method using reflection\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');\n $method->setAccessible(true);\n\n // Should not re-throw the exception, just log it\n $method->invoke($serviceMock, \\Jiminny\\Enums\\CrmObject::CONTACT);\n\n $this->assertTrue(true); // Test completed successfully\n }\n\n public function testSyncRemotelyDeletedObjectsWithErrorHandlingWithLogParams(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['syncRemotelyDeletedObjects'])\n ->getMock();\n\n // Mock team\n $team = $this->createMock(Team::class);\n $team->method('getUuid')->willReturn('team-uuid-789');\n $serviceMock->team = $team;\n\n // Mock logger to verify warning is logged\n $logger = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n\n // Use reflection to set the protected logger property\n $loggerReflection = new \\ReflectionClass($serviceMock);\n $loggerProperty = $loggerReflection->getProperty('logger');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($serviceMock, $logger);\n\n $exception = new \\Exception('Network timeout');\n\n // Expect syncRemotelyDeletedObjects to throw an exception\n $serviceMock->expects($this->once())\n ->method('syncRemotelyDeletedObjects')\n ->with(\\Jiminny\\Enums\\CrmObject::OPPORTUNITY)\n ->willThrowException($exception);\n\n // Additional log parameters\n $logParams = [\n 'syncType' => 'full',\n 'batchSize' => 100,\n ];\n\n // Expect warning to be logged with merged parameters\n $logger->expects($this->once())\n ->method('warning')\n ->with(\n '[Salesforce] Remotely deleted objects sync failed',\n [\n 'objectType' => 'opportunity',\n 'teamId' => 'team-uuid-789',\n 'reason' => 'Network timeout',\n 'syncType' => 'full',\n 'batchSize' => 100,\n ]\n );\n\n // Call the protected method using reflection\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');\n $method->setAccessible(true);\n\n // Should not re-throw the exception, just log it\n $method->invoke($serviceMock, \\Jiminny\\Enums\\CrmObject::OPPORTUNITY, $logParams);\n\n $this->assertTrue(true); // Test completed successfully\n }\n\n /**\n * @dataProvider crmObjectProvider\n */\n public function testSyncRemotelyDeletedObjectsWithErrorHandlingDifferentCrmObjects(\\Jiminny\\Enums\\CrmObject $crmObject): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['syncRemotelyDeletedObjects'])\n ->getMock();\n\n // Mock team\n $team = $this->createMock(Team::class);\n $team->method('getUuid')->willReturn('team-uuid-test');\n $serviceMock->team = $team;\n\n // Mock logger to verify warning is logged\n $logger = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n\n // Use reflection to set the protected logger property\n $loggerReflectionClass = new \\ReflectionClass($serviceMock);\n $loggerProperty = $loggerReflectionClass->getProperty('logger');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($serviceMock, $logger);\n\n $exception = new \\Exception('Test error');\n\n // Expect syncRemotelyDeletedObjects to throw an exception\n $serviceMock->expects($this->once())\n ->method('syncRemotelyDeletedObjects')\n ->with($crmObject)\n ->willThrowException($exception);\n\n // Expect warning to be logged with correct entity type\n $logger->expects($this->once())\n ->method('warning')\n ->with(\n '[Salesforce] Remotely deleted objects sync failed',\n [\n 'objectType' => $crmObject->value,\n 'teamId' => 'team-uuid-test',\n 'reason' => 'Test error',\n ]\n );\n\n // Call the protected method using reflection\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');\n $method->setAccessible(true);\n\n $method->invoke($serviceMock, $crmObject);\n\n $this->assertTrue(true); // Test completed successfully\n }\n\n public function testHandleObjectDeletionWithDeletedEntity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['deleteCrmObject'])\n ->getMock();\n\n $entity = $this->createMock(\\Jiminny\\Models\\Account::class);\n $crmData = ['IsDeleted' => true];\n\n $serviceMock->expects($this->once())\n ->method('deleteCrmObject')\n ->with($entity);\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('handleObjectDeletion');\n $method->setAccessible(true);\n\n $method->invoke($serviceMock, $entity, $crmData);\n }\n\n public function testHandleObjectDeletionWithNonDeletedEntity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['deleteCrmObject'])\n ->getMock();\n\n $entity = $this->createMock(\\Jiminny\\Models\\Contact::class);\n $crmData = ['IsDeleted' => false];\n\n $serviceMock->expects($this->never())\n ->method('deleteCrmObject');\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('handleObjectDeletion');\n $method->setAccessible(true);\n\n $method->invoke($serviceMock, $entity, $crmData);\n }\n\n public function testDeleteCrmObjectWithValidEntity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['dispatchDeleteCrmObjectJob'])\n ->getMock();\n\n $entity = $this->createMock(\\Jiminny\\Models\\Lead::class);\n $entity->expects($this->once())->method('delete');\n\n $serviceMock->expects($this->once())\n ->method('dispatchDeleteCrmObjectJob')\n ->with($entity);\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('deleteCrmObject');\n $method->setAccessible(true);\n\n $method->invoke($serviceMock, $entity);\n }\n\n public function testDeleteCrmObjectWithNullEntity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['dispatchDeleteCrmObjectJob'])\n ->getMock();\n\n $serviceMock->expects($this->never())\n ->method('dispatchDeleteCrmObjectJob');\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('deleteCrmObject');\n $method->setAccessible(true);\n\n $method->invoke($serviceMock, null);\n }\n\n public function testDispatchDeleteCrmObjectJobWithNullEntity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($service);\n $method = $reflection->getMethod('dispatchDeleteCrmObjectJob');\n $method->setAccessible(true);\n\n // Should return early without dispatching - no exception expected\n $method->invoke($service, null);\n\n $this->assertTrue(true); // Test completed successfully\n }\n\n public function testDispatchDeleteCrmObjectJobWithUnsupportedEntity(): void\n {\n $this->expectException(\\TypeError::class);\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n $unsupportedEntity = $this->createMock(\\stdClass::class);\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($service);\n $method = $reflection->getMethod('dispatchDeleteCrmObjectJob');\n\n // This will throw TypeError due to union type constraint\n $method->invoke($service, $unsupportedEntity);\n }\n\n public function testHandleEntityDeletionByProviderIdMethodExists(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n // Test that the method exists and is accessible via reflection\n $reflection = new \\ReflectionClass($service);\n $method = $reflection->getMethod('handleEntityDeletionByProviderId');\n $method->setAccessible(true);\n\n // Verify method exists and has correct parameters\n $this->assertTrue($method->isProtected());\n $this->assertEquals(2, $method->getNumberOfParameters());\n\n $parameters = $method->getParameters();\n $this->assertEquals('targetEntity', $parameters[0]->getName());\n $this->assertEquals('crmData', $parameters[1]->getName());\n }\n\n public function testSyncRemotelyDeletedObjectsWithNoResults(): void\n {\n // Create a real service instance to avoid mock property issues\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n // Mock queryHandler to throw NoResultsException\n $queryHandler = $this->createMock(QueryHandler::class);\n $queryHandler->expects($this->once())\n ->method('queryDeleted')\n ->with('Opportunity')\n ->willThrowException(new NoResultsException('No results'));\n\n // Set the queryHandler using reflection on the real service\n $reflection = new \\ReflectionClass($service);\n $queryHandlerProperty = $reflection->getProperty('queryHandler');\n $queryHandlerProperty->setAccessible(true);\n $queryHandlerProperty->setValue($service, $queryHandler);\n\n $result = self::invokePrivateMethod('syncRemotelyDeletedObjects', $service, [CrmObject::OPPORTUNITY]);\n\n $this->assertFalse($result);\n }\n\n public function testSyncRemotelyDeletedObjectsWithEmptyResults(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n // Mock queryHandler to return empty results\n $queryResult = $this->createMock(QueryResults::class);\n $queryResult->method('getResults')->willReturn([]);\n\n $queryHandler = $this->createMock(QueryHandler::class);\n $queryHandler->expects($this->once())\n ->method('queryDeleted')\n ->with('Opportunity')\n ->willReturn($queryResult);\n\n // Set the queryHandler using reflection on the real service\n $reflection = new \\ReflectionClass($service);\n $queryHandlerProperty = $reflection->getProperty('queryHandler');\n $queryHandlerProperty->setAccessible(true);\n $queryHandlerProperty->setValue($service, $queryHandler);\n\n $result = self::invokePrivateMethod('syncRemotelyDeletedObjects', $service, [CrmObject::OPPORTUNITY]);\n\n $this->assertFalse($result);\n }\n\n public function testSyncRemotelyDeletedObjectsWithUnsupportedCrmObject(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n // Mock queryHandler to return some deleted objects so we reach the match statement\n $deletedObjects = [\n ['id' => 'task1'],\n ['id' => 'task2'],\n ];\n $queryResult = $this->createMock(QueryResults::class);\n $queryResult->method('getResults')->willReturn($deletedObjects);\n\n $queryHandler = $this->createMock(QueryHandler::class);\n $queryHandler->expects($this->once())\n ->method('queryDeleted')\n ->with('Task') // ucfirst('task') = 'Task'\n ->willReturn($queryResult);\n\n self::setPrivateProperty($service, 'queryHandler', $queryHandler);\n\n $this->expectException(InvalidArgumentException::class);\n $this->expectExceptionMessage('Unsupported CrmObject: task');\n\n self::invokePrivateMethod('syncRemotelyDeletedObjects', $service, [CrmObject::TASK]);\n }\n\n public static function crmObjectProvider(): array\n {\n return [\n 'Account' => [CrmObject::ACCOUNT],\n 'Contact' => [CrmObject::CONTACT],\n 'Lead' => [CrmObject::LEAD],\n 'Opportunity' => [CrmObject::OPPORTUNITY],\n ];\n }\n\n public function testVerifyTaskExistsReturnsTrue(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-123', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-123');\n $activity->method('getId')->willReturn(456);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-123', ['Id', 'IsDeleted'])\n ->willReturn(['Id' => 'task-123', 'IsDeleted' => false]);\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertTrue($result);\n }\n\n public function testVerifyTaskExistsReturnsTrueForEvent(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:event-123', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('event-123');\n $activity->method('getId')->willReturn(456);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_EVENT);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Event', 'event-123', ['Id', 'IsDeleted'])\n ->willReturn(['Id' => 'event-123', 'IsDeleted' => false]);\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertTrue($result);\n }\n\n public function testVerifyTaskExistsReturnsFalseWhenDeleted(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-456', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-456');\n $activity->method('getId')->willReturn(789);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-456', ['Id', 'IsDeleted'])\n ->willReturn(['Id' => 'task-456', 'IsDeleted' => true]);\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertFalse($result);\n }\n\n public function testVerifyTaskExistsReturnsFalseWhenNotFound(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-999', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-999');\n $activity->method('getId')->willReturn(999);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-999', ['Id', 'IsDeleted'])\n ->willThrowException(new \\Jiminny\\Exceptions\\HttpNotFoundException('Task not found'));\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertFalse($result);\n }\n\n public function testVerifyTaskExistsReturnsFalseWhenNoPlaybook(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-no-playbook', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-no-playbook');\n $activity->method('getId')->willReturn(111);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn(null);\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertFalse($result);\n }\n\n public function testVerifyTaskExistsThrowsExceptionForTransientErrors(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-error', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-error');\n $activity->method('getId')->willReturn(888);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-error', ['Id', 'IsDeleted'])\n ->willThrowException(new \\RuntimeException('Network timeout'));\n\n $this->expectException(\\RuntimeException::class);\n $this->expectExceptionMessage('Network timeout');\n\n $service->verifyTaskExists($activity);\n }\n\n public function testVerifyTaskExistsCachesResults(): void\n {\n $cachedValue = null;\n Cache::shouldReceive('remember')\n ->twice()\n ->with('crm_task_exists:123:task-cached', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(function ($key, $ttl, $callback) use (&$cachedValue) {\n if ($cachedValue === null) {\n $cachedValue = $callback();\n }\n\n return $cachedValue;\n });\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-cached');\n $activity->method('getId')->willReturn(555);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-cached', ['Id', 'IsDeleted'])\n ->willReturn(['Id' => 'task-cached', 'IsDeleted' => false]);\n\n $result1 = $service->verifyTaskExists($activity);\n $result2 = $service->verifyTaskExists($activity);\n\n $this->assertTrue($result1);\n $this->assertTrue($result2);\n }\n\n public function testVerifyTaskExistsReturnsFalseForHttpBadRequestException(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-400', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-400');\n $activity->method('getId')->willReturn(400);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-400', ['Id', 'IsDeleted'])\n ->willThrowException(new \\Jiminny\\Exceptions\\HttpBadRequestException('Bad request'));\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertFalse($result);\n }\n\n public function testImportOpportunitySkipsWhenNoProfileAndNoAccount(): void\n {\n $crmData = [\n 'Id' => 'SF-NO-USER-1',\n 'Name' => 'Test Opportunity',\n 'OwnerId' => 'owner-no-profile',\n // No AccountId\n ];\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(\\Illuminate\\Events\\Dispatcher::class); // ← ADD THIS\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n\n $service = new Service(\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService\n );\n\n $config = $this->createMock(Configuration::class);\n\n // Mock profiles relation returning null (no profile found)\n $profilesRelation = \\Mockery::mock(\\Illuminate\\Database\\Eloquent\\Relations\\HasMany::class);\n $profilesRelation->shouldReceive('where')->with('crm_provider_id', 'owner-no-profile')->andReturnSelf();\n $profilesRelation->shouldReceive('first')->andReturn(null);\n\n $config->expects($this->once())\n ->method('profiles')\n ->willReturn($profilesRelation);\n\n $team = $this->createMock(Team::class);\n $team->method('getId')->willReturn(1);\n\n $logger = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n $logger->expects($this->once())\n ->method('error')\n ->with(\n '[Salesforce] | Skip import, no user_id found',\n ['id' => 'SF-NO-USER-1']\n );\n\n $reflection = new \\ReflectionClass($service);\n\n $configProperty = $reflection->getProperty('config');\n $configProperty->setAccessible(true);\n $configProperty->setValue($service, $config);\n\n $teamProperty = $reflection->getProperty('team');\n $teamProperty->setAccessible(true);\n $teamProperty->setValue($service, $team);\n\n $loggerProperty = $reflection->getProperty('logger');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($service, $logger);\n\n // Initialize profile property to avoid \"must not be accessed before initialization\" error\n $profileProperty = $reflection->getProperty('profile');\n $profileProperty->setAccessible(true);\n $profileProperty->setValue($service, null);\n\n $result = self::invokePrivateMethod('importOpportunity', $service, [$crmData]);\n\n $this->assertNull($result);\n }\n\n public function testImportOpportunitySkipsWhenProfileNotFound(): void\n {\n $crmData = [\n 'Id' => 'SF-NO-USER-2',\n 'Name' => 'Test Opportunity',\n 'OwnerId' => 'owner-not-found',\n // No AccountId - avoid complex account processing\n ];\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $eventDispatcher = $this->createMock(\\Illuminate\\Events\\Dispatcher::class); // ← ADD THIS\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n\n\n $service = new Service(\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService\n );\n\n $config = $this->createMock(Configuration::class);\n\n // Mock profiles relation returning null (no profile found)\n $profilesRelation = \\Mockery::mock(\\Illuminate\\Database\\Eloquent\\Relations\\HasMany::class);\n $profilesRelation->shouldReceive('where')->with('crm_provider_id', 'owner-not-found')->andReturnSelf();\n $profilesRelation->shouldReceive('first')->andReturn(null);\n\n $config->expects($this->once())\n ->method('profiles')\n ->willReturn($profilesRelation);\n\n $team = $this->createMock(Team::class);\n $team->method('getId')->willReturn(1);\n\n $logger = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n $logger->expects($this->once())\n ->method('error')\n ->with(\n '[Salesforce] | Skip import, no user_id found',\n ['id' => 'SF-NO-USER-2']\n );\n\n $reflection = new \\ReflectionClass($service);\n\n $configProperty = $reflection->getProperty('config');\n $configProperty->setAccessible(true);\n $configProperty->setValue($service, $config);\n\n $teamProperty = $reflection->getProperty('team');\n $teamProperty->setAccessible(true);\n $teamProperty->setValue($service, $team);\n\n $loggerProperty = $reflection->getProperty('logger');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($service, $logger);\n\n // Initialize profile property\n $profileProperty = $reflection->getProperty('profile');\n $profileProperty->setAccessible(true);\n $profileProperty->setValue($service, null);\n\n $result = self::invokePrivateMethod('importOpportunity', $service, [$crmData]);\n\n $this->assertNull($result);\n }\n}\n\nclass HasManyExtended extends HasMany\n{\n public function where()\n {\n }\n\n public function first()\n {\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"A","depth":4,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":5,"bounds":{"left":0.0,"top":0.0,"width":0.008333334,"height":0.03},"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Select All","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"320","depth":6,"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"320","depth":7,"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"5772","depth":8,"bounds":{"left":0.0,"top":0.0,"width":0.2048611,"height":0.024444444},"on_screen":false,"value":"5772","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"46ea38f7-c940-395d-9c79-0329d30e9ab7","depth":6,"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"46ea38f7-c940-395d-9c79-0329d30e9ab7","depth":7,"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"2024-11-26 09:21:15","depth":8,"bounds":{"left":0.0,"top":0.0,"width":0.2048611,"height":0.024444444},"on_screen":false,"value":"2024-11-26 09:21:15","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"401","depth":6,"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"401","depth":7,"on_screen":true,"role_description":"text"}]...
|
7732332105235147404
|
-6520523286572803295
|
idle
|
accessibility
|
NULL
|
Project: faVsco.js, menu
JY-20963-fix-import-on-de Project: faVsco.js, menu
JY-20963-fix-import-on-deleted-entity, menu
Start Listening for PHP Debug Connections
ServiceTest
Rerun 'PHPUnit: ServiceTest'
Debug 'ServiceTest'
Stop 'ServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Built-in Preview
Chrome
Firefox
Safari
Sync Changes
Hide This Notification
Code changed:
Hide
4
32
168
1
28
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Tests\Unit\Services\Crm\Salesforce;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Events\Dispatcher;
use Illuminate\Support\Facades\Cache;
use Jiminny\Component\Country\CountriesMap;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Enums\CrmObject;
use Jiminny\Exceptions\InvalidArgumentException;
use Jiminny\Exceptions\NoResultsException;
use Jiminny\Integrations\PlaybookResolver;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Playbook;
use Jiminny\Models\Team;
use Jiminny\Models\TeamSettings;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\ContactRoleRepository;
use Jiminny\Repositories\Crm\FieldDataRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\Salesforce\Client;
use Jiminny\Services\Crm\Salesforce\PayloadBuilder;
use Jiminny\Services\Crm\Salesforce\QueryBuilder;
use Jiminny\Services\Crm\Salesforce\QueryHandler;
use Jiminny\Services\Crm\Salesforce\QueryIterator;
use Jiminny\Services\Crm\Salesforce\QueryResults;
use Jiminny\Services\Crm\Salesforce\Service;
use PHPUnit\Framework\MockObject\MockObject;
use Tests\TestCase;
use Tests\Unit\Traits\TestPrivateMethod;
class ServiceTest extends TestCase
{
use TestPrivateMethod;
public function testFetchAndAssociateRelatedActivity(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$payloadBuilder->method('addCustomLogicFieldsPayload')
->willReturnCallback(function ($activity, $payload) {
return $payload;
});
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods(['fetchRelatedActivity', 'getPlaybook', 'getPlaybookCategory', 'updateRecord'])
->getMock();
$serviceMock->expects($this->once())
->method('fetchRelatedActivity')
->willReturn([
'Id' => 'testId',
'Type' => null,
'OwnerId' => 'testerUser',
'Description' => 'Test description',
]);
$user = $this->createMock(User::class);
$team = $this->createMock(Team::class);
$user->method('getAttribute')->with('team')->willReturn($team);
$playbook = $this->createMock(Playbook::class);
$playbook->method('getActivityField')->willReturn(null);
$playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_EVENT);
$serviceMock->expects($this->once())
->method('getPlaybook')
->with($user)
->willReturn($playbook);
$serviceMock->expects($this->never())
->method('getPlaybookCategory');
$serviceMock->expects($this->never())
->method('updateRecord');
$fieldDataRepository = $this->createMock(FieldDataRepository::class);
$fieldDataRepository->method('getActivityFieldData')->willReturn(collect([]));
app()->instance(FieldDataRepository::class, $fieldDataRepository);
$config = $this->createMock(Configuration::class);
$profilesRelation = $this->getMockBuilder(\Illuminate\Database\Eloquent\Relations\HasMany::class)
->disableOriginalConstructor()
->onlyMethods(['get'])
->addMethods(['where'])
->getMock();
$profilesRelation->method('where')->willReturnSelf();
$profilesRelation->method('get')->willReturn(collect([]));
$config->method('profiles')->willReturn($profilesRelation);
$serviceMock->config = $config;
$serviceMock->profile = null;
$actualStartTime = \Carbon\Carbon::now();
$activity = $this->getMockBuilder(Activity::class)
->disableOriginalConstructor()
->onlyMethods(['update', 'hasProspect'])
->getMock();
$activity->method('update')->willReturn(true);
$activity->method('hasProspect')->willReturn(true);
$activity->type = Activity::TYPE_CONFERENCE;
$activity->provider = Activity::PROVIDER_TWILIO;
$activity->lead_id = 1;
$activity->user_id = 0;
$activity->id_string = 'test-activity-id';
$activity->user = $user;
$activity->actual_start_time = $actualStartTime;
$activity->uuid = 'c53d8320-f556-4cee-a2f8-5f232f454ca4';
app()->bind(PlaybookResolver::class, function () use ($user) {
$playbook = $this->createMock(Playbook::class);
$playbookResolver = $this->createMock(PlaybookResolver::class);
$playbookResolver->expects($this->once())
->method('resolvePlaybookByUser')
->with($user)
->willReturn($playbook);
return $playbookResolver;
});
$data = $serviceMock->fetchAndAssociateRelatedActivity($activity);
$this->assertInstanceOf(Activity::class, $data);
$this->assertEquals(Activity::TYPE_CONFERENCE, $data->getType());
$this->assertEquals($actualStartTime->getTimestamp(), $data->getActualStartTime()->getTimestamp());
}
public function testFetchAndAssociateRelatedActivitySkipsForTaskBasedPlaybook(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods(['fetchRelatedActivity', 'getPlaybook'])
->getMock();
$user = $this->createMock(User::class);
$playbook = $this->createMock(Playbook::class);
$playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);
$playbook->method('getId')->willReturn(123);
$serviceMock->expects($this->once())
->method('getPlaybook')
->with($user)
->willReturn($playbook);
$serviceMock->expects($this->never())
->method('fetchRelatedActivity');
$activity = $this->getMockBuilder(Activity::class)
->disableOriginalConstructor()
->onlyMethods(['hasProspect', 'getUuid'])
->getMock();
$activity->method('hasProspect')->willReturn(true);
$activity->method('getUuid')->willReturn('c53d8320-f556-4cee-a2f8-5f232f454ca4');
$activity->type = Activity::TYPE_CONFERENCE;
$activity->actual_start_time = \Carbon\Carbon::now();
$activity->user = $user;
$result = $serviceMock->fetchAndAssociateRelatedActivity($activity);
$this->assertNull($result);
}
public function testFetchAndAssociateRelatedActivityReturnsNullForNonConference(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$serviceMock = new Service(
client: $client,
payloadBuilder: $payloadBuilder,
eventDispatcher: $eventDispatcher,
countriesMap: $countriesMap,
prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)
);
$activity = $this->getMockBuilder(Activity::class)
->disableOriginalConstructor()
->getMock();
$activity->type = Activity::TYPE_SOFTPHONE;
$result = $serviceMock->fetchAndAssociateRelatedActivity($activity);
$this->assertNull($result);
}
public function testFetchAndAssociateRelatedActivityReturnsNullWhenNoStartTime(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$serviceMock = new Service(
client: $client,
payloadBuilder: $payloadBuilder,
eventDispatcher: $eventDispatcher,
countriesMap: $countriesMap,
prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)
);
$activity = $this->getMockBuilder(Activity::class)
->disableOriginalConstructor()
->getMock();
$activity->type = Activity::TYPE_CONFERENCE;
$activity->actual_start_time = null;
$activity->scheduled_start_time = null;
$result = $serviceMock->fetchAndAssociateRelatedActivity($activity);
$this->assertNull($result);
}
public function testFetchAndAssociateRelatedActivityReturnsNullWhenNoProspect(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods(['getPlaybook'])
->getMock();
$serviceMock->expects($this->never())
->method('getPlaybook');
$activity = $this->getMockBuilder(Activity::class)
->disableOriginalConstructor()
->onlyMethods(['hasProspect', 'getUuid'])
->getMock();
$activity->method('hasProspect')->willReturn(false);
$activity->method('getUuid')->willReturn('c53d8320-f556-4cee-a2f8-5f232f454ca4');
$activity->type = Activity::TYPE_CONFERENCE;
$activity->actual_start_time = \Carbon\Carbon::now();
$result = $serviceMock->fetchAndAssociateRelatedActivity($activity);
$this->assertNull($result);
}
public function testMatchExactlyByEmail(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods([])
->getMock();
$profile = new Profile();
$profile->setAttribute('id', bin2hex(random_bytes(8)));
$serviceMock->profile = $profile;
$team = $this->createMock(Team::class);
$serviceMock->team = $team;
$data = $serviceMock->matchExactlyByEmail(bin2hex(random_bytes(8)) . '[EMAIL]');
$this->assertEquals(null, $data);
}
public function testMatchDomainFromEmail(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$queryIterator = $this->createMock(QueryIterator::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$config = $this->createMock(Configuration::class);
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('search')
->willReturn($queryIterator);
return $handler;
});
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods(['convertCrmData'])
->getMock();
$profile = new Profile();
$profile->account_fields = 'Field1, Field2, Field3';
$serviceMock->profile = $profile;
$serviceMock->expects($this->once())
->method('convertCrmData')
->willReturn(['test']);
$this->app->bind(QueryBuilder::class, function () {
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->expects($this->once())
->method('buildMatchByDomainQuery')
->with('[EMAIL]')
->willReturn('FIND {[EMAIL]} IN ALL FIELDS RETURNING Account(Id)');
return $queryBuilder;
});
$team = $this->createMock(Team::class);
$serviceMock->team = $team;
$serviceMock->config = $config;
$data = $serviceMock->matchByDomain('[EMAIL]');
$this->assertEquals(['test'], $data);
}
public function testBuildTaskSearchFields(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$service = new Service(
client: $client,
payloadBuilder: $payloadBuilder,
eventDispatcher: $eventDispatcher,
countriesMap: $countriesMap,
prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)
);
$fields = $service->buildTaskSearchFields();
$expectedFields = ['Id', 'WhoId', 'WhatId', 'AccountId'];
$this->assertEquals($expectedFields, $fields);
}
public function testMapCrmObjects(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$service = new Service(
client: $client,
payloadBuilder: $payloadBuilder,
eventDispatcher: $eventDispatcher,
countriesMap: $countriesMap,
prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class),
);
$sampleTask = [
'WhoId' => '003sampleWhoId',
'AccountId' => 'sampleAccountId',
'WhatId' => 'sampleWhatId',
];
$activityData = $service->mapCrmObjects($sampleTask);
$expectedActivityData = [
'contact' => '003sampleWhoId',
'account' => 'sampleAccountId',
'opportunity' => 'sampleWhatId',
];
$this->assertEquals($expectedActivityData, $activityData);
}
public function testGetInstalledAppVersion(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$queryIterator = $this->createMock(QueryIterator::class);
$queryIterator->expects($this->any())
->method('current')->willReturn([
'SubscriberPackageVersion' => [
'MajorVersion' => '1',
'MinorVersion' => '0',
'PatchVersion' => '1',
'BuildNumber' => '0',
],
]);
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('metadata')
->willReturn($queryIterator);
return $handler;
});
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods(array_diff(get_class_methods(Service::class), ['getInstalledAppVersion']))
->getMock();
$version = $serviceMock->getInstalledAppVersion();
$this->assertEquals('1010', $version);
}
public function testSyncProfiles(): void
{
$userToSearch = null;
$team = $this->createMock(Team::class);
$config = $this->createMockedConfiguration();
$config->expects($this->once())
->method('getId')
->willReturn(1);
$salesforceUser = [
'Email' => '[EMAIL]',
'UserPreferencesLightningExperiencePreferred' => true,
'CallCenterId' => '123',
'Id' => '456',
'ProfileId' => '789',
];
app()->bind(QueryBuilder::class, function () use ($userToSearch) {
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->expects($this->once())
->method('buildGetUsersQuery')
->with($userToSearch)
->willReturn('SELECT * FROM Users');
return $queryBuilder;
});
$queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('query')
->with('SELECT * FROM Users')
->willReturn($queryIterator);
return $handler;
});
$user = $this->createMock(User::class);
$user->expects($this->once())
->method('getId')
->willReturn(123);
$this->mockTeamRepository($team, $salesforceUser, $user);
$profileRepository = $this->createMock(ProfileRepository::class);
$profileRepository->expects($this->once())
->method('updateOrCreateProfile')
->with(
$user,
[
'crm_configuration_id' => 1,
'crm_provider_id' => '456',
],
[
'user_id' => 123,
'edition' => Profile::EDITION_LIGHTNING,
'has_external_cti' => true,
'crm_profile_id' => '789',
]
)
->willReturn(new Profile());
$this->app->instance(ProfileRepository::class, $profileRepository);
$serviceMock = $this->getServiceMock();
$serviceMock->team = $team;
$serviceMock->config = $config;
$result = $serviceMock->syncProfiles($userToSearch);
$this->assertNull($result);
}
public function testSyncProfilesEmailIsNull(): void
{
$userToSearch = $this->createMock(User::class);
$salesforceUser = [
'Email' => null,
];
app()->bind(QueryBuilder::class, function () {
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->expects($this->once())
->method('buildGetUsersQuery')
->with(null)
->willReturn('SELECT * FROM Users');
return $queryBuilder;
});
$queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->exactly(2))
->method('query')
->willReturn($queryIterator);
return $handler;
});
$team = $this->createMock(Team::class);
$user = $this->createMock(User::class);
$this->mockTeamRepository($team, $salesforceUser, $user, false);
$config = $this->createMock(Configuration::class);
$serviceMock = $this->getServiceMock();
$serviceMock->team = $team;
$serviceMock->config = $config;
$profile = $serviceMock->syncProfiles(null);
$this->assertNull($profile);
}
public function testSyncProfilesUserToSearchMatchesCurrentUser(): void
{
$userToSearch = $this->createMock(User::class);
$userToSearch->expects($this->once())
->method('getId')
->willReturn(123);
$team = $this->createMock(Team::class);
$config = $this->createMock(Configuration::class);
$config->expects($this->once())
->method('getId')
->willReturn(1);
$salesforceUser = [
'Email' => '[EMAIL]',
'UserPreferencesLightningExperiencePreferred' => true,
'CallCenterId' => '123',
'Id' => '456',
'ProfileId' => '789',
];
app()->bind(QueryBuilder::class, function () use ($userToSearch) {
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->expects($this->once())
->method('buildGetUsersQuery')
->with($userToSearch)
->willReturn('SELECT * FROM Users');
return $queryBuilder;
});
$queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('query')
->with('SELECT * FROM Users')
->willReturn($queryIterator);
return $handler;
});
$user = $this->createMock(User::class);
$user->expects($this->exactly(2))
->method('getId')
->willReturn(123);
$this->mockTeamRepository($team, $salesforceUser, $user);
$profileRepository = $this->createMock(ProfileRepository::class);
$profileRepository->expects($this->once())
->method('updateOrCreateProfile')
->with(
$user,
[
'crm_configuration_id' => 1,
'crm_provider_id' => '456',
],
[
'user_id' => 123,
'edition' => Profile::EDITION_LIGHTNING,
'has_external_cti' => true,
'crm_profile_id' => '789',
]
)
->willReturn(new Profile());
$this->app->instance(ProfileRepository::class, $profileRepository);
$serviceMock = $this->getServiceMock();
$serviceMock->team = $team;
$serviceMock->config = $config;
$profile = $serviceMock->syncProfiles($userToSearch);
$this->assertInstanceOf(Profile::class, $profile);
}
public function testSyncProfilesWithCustomValidation(): void
{
$userToSearch = null;
$team = $this->createMock(Team::class);
$config = $this->createMockedConfiguration();
$config->expects($this->atLeastOnce()) // Changed from once() to atLeastOnce()
->method('getId')
->willReturn(1);
$salesforceUser = [
'Email' => '[EMAIL]',
'UserPreferencesLightningExperiencePreferred' => true,
'CallCenterId' => '123',
'Id' => '456',
'ProfileId' => '789',
'CustomField' => 'CustomValue',
];
$customRules = [
['field' => 'CustomField', 'value' => 'CustomValue'],
];
$this->mockQueryBuilderAndHandler($userToSearch, [$salesforceUser]);
$user = $this->createMock(User::class);
$user->expects($this->once())
->method('getId')
->willReturn(123);
$this->mockTeamRepository($team, $salesforceUser, $user, true, $customRules);
$this->mockProfileRepository($user);
$serviceMock = $this->getServiceMock();
$serviceMock->team = $team;
$serviceMock->config = $config;
$profile = $serviceMock->syncProfiles($userToSearch);
$this->assertNull($profile);
}
public function testSyncProfilesWithCustomValidationFailing(): void
{
$userToSearch = null;
$team = $this->createMock(Team::class);
$config = $this->createMockedConfiguration();
$salesforceUser = [
'Email' => '[EMAIL]',
'UserPreferencesLightningExperiencePreferred' => true,
'CallCenterId' => '123',
'Id' => '456',
'ProfileId' => '789',
'CustomField' => 'WrongValue',
];
$customRules = [
['field' => 'CustomField', 'value' => 'CustomValue'],
];
$this->mockQueryBuilderAndHandler($userToSearch, [$salesforceUser]);
$teamRepository = $this->getMockForAbstractClass(TeamRepository::class, [], '', false, true, true, ['findActiveTeamMemberByEmail', 'getTeamSetting']);
$teamSettings = $this->createMock(TeamSettings::class);
$teamSettings->method('getValueType')
->willReturn('array');
$teamSettings->method('getValue')
->willReturn(json_encode($customRules));
$teamRepository->expects($this->once())
->method('getTeamSetting')
->with($team, 'custom_profile_validation')
->willReturn($teamSettings);
app()->bind(TeamRepository::class, function () use ($teamRepository) {
return $teamRepository;
});
$profileRepository = $this->createMock(ProfileRepository::class);
$profileRepository->expects($this->never())
->method('updateOrCreateProfile');
$this->app->instance(ProfileRepository::class, $profileRepository);
$serviceMock = $this->getServiceMock();
$serviceMock->team = $team;
$serviceMock->config = $config;
$result = $serviceMock->syncProfiles($userToSearch);
$this->assertNull($result);
}
public function testGetContactRolesFromCrm(): void
{
$contactRoles = [
[
'Id' => '1',
'ContactId' => 'Contact1',
'OpportunityId' => 'Opportunity1',
'Opportunity' => ['OwnerId' => 'Owner1'],
'IsPrimary' => true,
'Role' => 'Decision Maker',
],
];
$expectedResponse = [
[
'id' => '1',
'contactId' => 'Contact1',
'opportunityId' => 'Opportunity1',
'ownerId' => 'Owner1',
'isPrimary' => true,
'role' => 'Decision Maker',
],
];
$this->bindQueryIterator($contactRoles);
$serviceMock = $this->getServiceMock();
$data = $serviceMock->getContactRolesFromCrm(now()->subDay());
$this->assertEquals($expectedResponse, $data);
}
public function testGetContactRolesFromCrmNoResult(): void
{
$this->bindQueryIterator([]);
$serviceMock = $this->getServiceMock();
$data = $serviceMock->getContactRolesFromCrm(now()->subDay());
$this->assertEquals([], $data);
}
public function testSyncContactRoles(): void
{
$contactRoles = [
[
'id' => '1',
'contactId' => 'Contact1',
'opportunityId' => 'Opportunity1',
'ownerId' => 'Owner1',
'isPrimary' => true,
'role' => 'Decision Maker',
],
];
app()->bind(ContactRoleRepository::class, function () {
$contactRoleRepository = $this->createMock(ContactRoleRepository::class);
$contactRoleRepository->expects($this->once())
->method('saveContactRoles');
return $contactRoleRepository;
});
$serviceMock = $this->getServiceMock([
'getContactRolesFromCrm',
'syncRemotelyDeletedContactRoles',
'syncContact',
'syncOpportunity',
]);
$config = $this->createMock(Configuration::class);
$hasMany = $this->createMock(HasManyExtended::class);
$hasMany->expects($this->exactly(2))
->method('where')
->willReturn($hasMany);
$hasMany->expects($this->exactly(2))
->method('first')
->willReturn(
$this->createMock(Contact::class),
$this->createMock(Opportunity::class)
);
$config->expects($this->once())
->method('contacts')
->willReturn($hasMany);
$config->expects($this->once())
->method('opportunities')
->willReturn($hasMany);
$serviceMock->config = $config;
$serviceMock->expects($this->once())
->method('getContactRolesFromCrm')
->willReturn($contactRoles);
$serviceMock->expects($this->once())
->method('syncRemotelyDeletedContactRoles');
$data = $serviceMock->syncContactRoles(now()->subDay());
$this->assertEquals(1, $data);
}
public function testSyncRemotelyDeletedContactRoles(): void
{
$contactRoles = [
[
'id' => '1',
'crm_provider_id' => '1',
],
];
app()->bind(QueryHandler::class, function () use ($contactRoles) {
$queryResults = new QueryResults($contactRoles, 1, true, null);
$handler = $this->createMock(QueryHandler::class);
$handler->method('queryDeleted')
->willReturn($queryResults);
return $handler;
});
app()->bind(ContactRoleRepository::class, function () {
$contactRoleRepository = $this->createMock(ContactRoleRepository::class);
$contactRoleRepository->expects($this->once())
->method('deleteContactRoles');
return $contactRoleRepository;
});
$serviceMock = $this->getServiceMock();
$serviceMock->team = $this->createMock(Team::class);
$data = $this->invokePrivateMethod('syncRemotelyDeletedContactRoles', $serviceMock, []);
$this->assertTrue($data);
}
private function bindQueryIterator(array $queryResult): void
{
/** @var Client $client */
$client = $this->createMock(Client::class);
$queryIterator = new QueryIterator(
$client,
new QueryResults($queryResult, 1, true, null)
);
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('query')
->willReturn($queryIterator);
return $handler;
});
}
public static function getOpportunitySortOrderDataProvider(): array
{
return [
'all open recently updated' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED, ['LastModifiedDate DESC', true]],
'all open recently created' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED, ['CreatedDate DESC', true]],
'all open oldest created' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED, ['CreatedDate ASC', true]],
'all recently updated' => [Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED, ['LastModifiedDate DESC', false]],
'default' => ['unknown', ['LastModifiedDate DESC', true]],
];
}
private function createMockedConfiguration(): Configuration
{
$config = $this->createMock(Configuration::class);
$profilesRelation = $this->getMockBuilder(\Illuminate\Database\Eloquent\Relations\HasMany::class)
->disableOriginalConstructor()
->onlyMethods(['get'])
->addMethods(['where', 'first'])
->getMock();
$profilesRelation->method('where')->willReturnSelf();
$profilesRelation->method('get')->willReturn(collect([]));
$profilesRelation->method('first')->willReturn(null);
$config->method('profiles')->willReturn($profilesRelation);
return $config;
}
private function getServiceMock(array $onlyMethods = []): MockObject&Service
{
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$this->createMock(Client::class),
$this->createMock(PayloadBuilder::class),
$this->createMock(Dispatcher::class),
$this->createMock(CountriesMap::class),
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods($onlyMethods)
->getMock();
$serviceMock->profile = $this->createMock(Profile::class);
return $serviceMock;
}
private function mockQueryBuilderAndHandler($userToSearch, $salesforceUsers): void
{
app()->bind(QueryBuilder::class, function () use ($userToSearch) {
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->expects($this->once())
->method('buildGetUsersQuery')
->with($userToSearch)
->willReturn('SELECT * FROM Users');
return $queryBuilder;
});
$queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults($salesforceUsers, count($salesforceUsers), true, null));
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('query')
->willReturn($queryIterator);
return $handler;
});
}
private function mockTeamRepository(
Team $team,
array $salesforceUser,
?User $user = null,
bool $userSearch = true,
array $customRules = []
): void {
$teamRepository = $this->getMockForAbstractClass(TeamRepository::class, [], '', false, true, true, ['findActiveTeamMemberByEmail', 'getTeamSetting']);
if ($userSearch) {
$teamRepository->expects($this->once())
->method('findActiveTeamMemberByEmail')
->with($team, $salesforceUser['Email'])
->willReturn($user);
}
$teamSettings = $this->createMock(TeamSettings::class);
$teamSettings->method('getValueType')
->willReturn('array');
$teamSettings->method('getValue')
->willReturn(json_encode($customRules));
$teamRepository->expects($this->once())
->method('getTeamSetting')
->with($team, 'custom_profile_validation')
->willReturn($teamSettings);
app()->bind(TeamRepository::class, function () use ($teamRepository) {
return $teamRepository;
});
}
private function mockProfileRepository(User $user): void
{
$profileRepository = $this->createMock(ProfileRepository::class);
$profileRepository->expects($this->once())
->method('updateOrCreateProfile')
->with(
$user,
[
'crm_configuration_id' => 1,
'crm_provider_id' => '456',
],
[
'user_id' => 123,
'edition' => Profile::EDITION_LIGHTNING,
'has_external_cti' => true,
'crm_profile_id' => '789',
]
)
->willReturn(new Profile());
$this->app->instance(ProfileRepository::class, $profileRepository);
}
public function testBuildEnhancedNoteDecodesHtmlEntities(): void
{
$service = $this->getServiceMock(['createRecord']);
$profile = new Profile();
$profile->setAttribute('crm_provider_id', 'owner-123');
$service->profile = $profile;
$service->expects($this->exactly(2))
->method('createRecord')
->willReturnOnConsecutiveCalls('note-id-123', 'link-id-456');
$bodyWithEntities = 'Welch's current challenges and Facebook's Club';
$result = $this->invokePrivateMethod('buildEnhancedNote', $service, [
'Test Title',
$bodyWithEntities,
'object-id-789',
]);
$this->assertEquals('note-id-123', $result);
}
public function testBuildEnhancedNoteSanitizesWithoutQuotes(): void
{
$service = $this->getServiceMock(['createRecord']);
$profile = new Profile();
$profile->setAttribute('crm_provider_id', 'owner-456');
$service->profile = $profile;
$service->expects($this->exactly(2))
->method('createRecord')
->willReturnCallback(function ($type, $data) {
if ($type === 'ContentNote') {
$decoded = base64_decode($data['Content']);
$this->assertStringContainsString("Welch's", $decoded);
$this->assertStringNotContainsString(''', $decoded);
$this->assertStringNotContainsString('&#039;', $decoded);
$this->assertStringContainsString('<script>', $decoded);
return 'note-id-456';
}
return 'link-id-789';
});
$bodyWithMixedContent = "Welch's and <script>alert('xss')</script>";
$result = $this->invokePrivateMethod('buildEnhancedNote', $service, [
'Test Title',
$bodyWithMixedContent,
'object-id-123',
]);
$this->assertEquals('note-id-456', $result);
}
public function testBuildEnhancedNoteConvertsLineBreaks(): void
{
$service = $this->getServiceMock(['createRecord']);
$profile = new Profile();
$profile->setAttribute('crm_provider_id', 'owner-789');
$service->profile = $profile;
$service->expects($this->exactly(2))
->method('createRecord')
->willReturnCallback(function ($type, $data) {
if ($type === 'ContentNote') {
$decoded = base64_decode($data['Content']);
$this->assertStringContainsString('<br>', $decoded);
$this->assertStringNotContainsString('<br />', $decoded);
return 'note-id-789';
}
return 'link-id-012';
});
$bodyWithLineBreaks = "Line 1\nLine 2\nLine 3";
$result = $this->invokePrivateMethod('buildEnhancedNote', $service, [
'Test Title',
$bodyWithLineBreaks,
'object-id-456',
]);
$this->assertEquals('note-id-789', $result);
}
public function testBuildEnhancedNoteHandlesComplexScenario(): void
{
$service = $this->getServiceMock(['createRecord']);
$profile = new Profile();
$profile->setAttribute('crm_provider_id', 'owner-complex');
$service->profile = $profile;
$service->expects($this->exactly(2))
->method('createRecord')
->willReturnCallback(function ($type, $data) {
if ($type === 'ContentNote') {
$decoded = base64_decode($data['Content']);
$this->assertStringContainsString("Welch's", $decoded);
$this->assertStringContainsString("Facebook's Club", $decoded);
$this->assertStringContainsString("Arctics'", $decoded);
$this->assertStringNotContainsString(''', $decoded);
$this->assertStringNotContainsString('&#039;', $decoded);
$this->assertStringContainsString('<br>', $decoded);
$this->assertStringContainsString('<', $decoded);
$this->assertStringContainsString('>', $decoded);
return 'note-complex';
}
return 'link-complex';
});
$complexBody = "Summary:\n---------\nThe call focused on understanding Welch's current challenges and exploring how Arctics' Revenue Growth Management solutions could support their strategic goals.\n\n• John SMith discussed his role as a category advisor for Google and Facebook's Club, emphasizing the importance of market research and advising on product assortment.\n• Madona introduced Arctics' Virtual Shoppers AI, which simulates consumer <behavior> to optimize pricing and promotional strategies.";
$result = $this->invokePrivateMethod('buildEnhancedNote', $service, [
'Jiminny Transcription Summary',
$complexBody,
'task-id-001',
]);
$this->assertEquals('note-complex', $result);
}
public function testSyncRemotelyDeletedObjectsWithErrorHandlingSuccess(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$prospectPhotoPathService,
])
->onlyMethods(['syncRemotelyDeletedObjects'])
->getMock();
// Mock team
$team = $this->createMock(Team::class);
$team->method('getUuid')->willReturn('team-uuid-123');
$serviceMock->team = $team;
// Expect syncRemotelyDeletedObjects to be called once and succeed
$serviceMock->expects($this->once())
->method('syncRemotelyDeletedObjects')
->with(\Jiminny\Enums\CrmObject::ACCOUNT);
// Call the protected method using reflection
$reflection = new \ReflectionClass($serviceMock);
$method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');
$method->setAccessible(true);
// Should not throw any exceptions
$method->invoke($serviceMock, \Jiminny\Enums\CrmObject::ACCOUNT);
$this->assertTrue(true); // Test completed successfully
}
public function testSyncRemotelyDeletedObjectsWithErrorHandlingFailure(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$prospectPhotoPathService,
])
->onlyMethods(['syncRemotelyDeletedObjects'])
->getMock();
// Mock team
$team = $this->createMock(Team::class);
$team->method('getUuid')->willReturn('team-uuid-456');
$serviceMock->team = $team;
// Mock logger to verify warning is logged
$logger = $this->createMock(\Psr\Log\LoggerInterface::class);
// Use reflection to set the protected logger property
$reflection = new \ReflectionClass($serviceMock);
$loggerProperty = $reflection->getProperty('logger');
$loggerProperty->setAccessible(true);
$loggerProperty->setValue($serviceMock, $logger);
$exception = new \Exception('Sync failed due to API error');
// Expect syncRemotelyDeletedObjects to throw an exception
$serviceMock->expects($this->once())
->method('syncRemotelyDeletedObjects')
->with(\Jiminny\Enums\CrmObject::CONTACT)
->willThrowException($exception);
// Expect warning to be logged with correct message and parameters
$logger->expects($this->once())
->method('warning')
->with(
'[Salesforce] Remotely deleted objects sync failed',
[
'objectType' => 'contact',
'teamId' => 'team-uuid-456',
'reason' => 'Sync failed due to API error',
]
);
// Call the protected method using reflection
$reflection = new \ReflectionClass($serviceMock);
$method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');
$method->setAccessible(true);
// Should not re-throw the exception, just log it
$method->invoke($serviceMock, \Jiminny\Enums\CrmObject::CONTACT);
$this->assertTrue(true); // Test completed successfully
}
public function testSyncRemotelyDeletedObjectsWithErrorHandlingWithLogParams(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$prospectPhotoPathService,
])
->onlyMethods(['syncRemotelyDeletedObjects'])
->getMock();
// Mock team
$team = $this->createMock(Team::class);
$team->method('getUuid')->willReturn('team-uuid-789');
$serviceMock->team = $team;
// Mock logger to verify warning is logged
$logger = $this->createMock(\Psr\Log\LoggerInterface::class);
// Use reflection to set the protected logger property
$loggerReflection = new \ReflectionClass($serviceMock);
$loggerProperty = $loggerReflection->getProperty('logger');
$loggerProperty->setAccessible(true);
$loggerProperty->setValue($serviceMock, $logger);
$exception = new \Exception('Network timeout');
// Expect syncRemotelyDeletedObjects to throw an exception
$serviceMock->expects($this->once())
->method('syncRemotelyDeletedObjects')
->with(\Jiminny\Enums\CrmObject::OPPORTUNITY)
->willThrowException($exception);
// Additional log parameters
$logParams = [
'syncType' => 'full',
'batchSize' => 100,
];
// Expect warning to be logged with merged parameters
$logger->expects($this->once())
->method('warning')
->with(
'[Salesforce] Remotely deleted objects sync failed',
[
'objectType' => 'opportunity',
'teamId' => 'team-uuid-789',
'reason' => 'Network timeout',
'syncType' => 'full',
'batchSize' => 100,
]
);
// Call the protected method using reflection
$reflection = new \ReflectionClass($serviceMock);
$method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');
...
|
70623
|
NULL
|
NULL
|
NULL
|
|
70626
|
2537
|
6
|
2026-05-22T10:24:36.278464+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779445476278_m2.jpg...
|
PhpStorm
|
faVsco.js – ServiceTest.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20963-fix-import-on-de Project: faVsco.js, menu
JY-20963-fix-import-on-deleted-entity, menu
Start Listening for PHP Debug Connections
ServiceTest
Rerun 'PHPUnit: ServiceTest'
Debug 'ServiceTest'
Stop 'ServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Built-in Preview
Chrome
Firefox
Safari
Sync Changes
Hide This Notification
Code changed:
Hide
4
32
168
1
28
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Tests\Unit\Services\Crm\Salesforce;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Events\Dispatcher;
use Illuminate\Support\Facades\Cache;
use Jiminny\Component\Country\CountriesMap;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Enums\CrmObject;
use Jiminny\Exceptions\InvalidArgumentException;
use Jiminny\Exceptions\NoResultsException;
use Jiminny\Integrations\PlaybookResolver;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Playbook;
use Jiminny\Models\Team;
use Jiminny\Models\TeamSettings;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\ContactRoleRepository;
use Jiminny\Repositories\Crm\FieldDataRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\Salesforce\Client;
use Jiminny\Services\Crm\Salesforce\PayloadBuilder;
use Jiminny\Services\Crm\Salesforce\QueryBuilder;
use Jiminny\Services\Crm\Salesforce\QueryHandler;
use Jiminny\Services\Crm\Salesforce\QueryIterator;
use Jiminny\Services\Crm\Salesforce\QueryResults;
use Jiminny\Services\Crm\Salesforce\Service;
use PHPUnit\Framework\MockObject\MockObject;
use Tests\TestCase;
use Tests\Unit\Traits\TestPrivateMethod;
class ServiceTest extends TestCase
{
use TestPrivateMethod;
public function testFetchAndAssociateRelatedActivity(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$payloadBuilder->method('addCustomLogicFieldsPayload')
->willReturnCallback(function ($activity, $payload) {
return $payload;
});
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods(['fetchRelatedActivity', 'getPlaybook', 'getPlaybookCategory', 'updateRecord'])
->getMock();
$serviceMock->expects($this->once())
->method('fetchRelatedActivity')
->willReturn([
'Id' => 'testId',
'Type' => null,
'OwnerId' => 'testerUser',
'Description' => 'Test description',
]);
$user = $this->createMock(User::class);
$team = $this->createMock(Team::class);
$user->method('getAttribute')->with('team')->willReturn($team);
$playbook = $this->createMock(Playbook::class);
$playbook->method('getActivityField')->willReturn(null);
$playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_EVENT);
$serviceMock->expects($this->once())
->method('getPlaybook')
->with($user)
->willReturn($playbook);
$serviceMock->expects($this->never())
->method('getPlaybookCategory');
$serviceMock->expects($this->never())
->method('updateRecord');
$fieldDataRepository = $this->createMock(FieldDataRepository::class);
$fieldDataRepository->method('getActivityFieldData')->willReturn(collect([]));
app()->instance(FieldDataRepository::class, $fieldDataRepository);
$config = $this->createMock(Configuration::class);
$profilesRelation = $this->getMockBuilder(\Illuminate\Database\Eloquent\Relations\HasMany::class)
->disableOriginalConstructor()
->onlyMethods(['get'])
->addMethods(['where'])
->getMock();
$profilesRelation->method('where')->willReturnSelf();
$profilesRelation->method('get')->willReturn(collect([]));
$config->method('profiles')->willReturn($profilesRelation);
$serviceMock->config = $config;
$serviceMock->profile = null;
$actualStartTime = \Carbon\Carbon::now();
$activity = $this->getMockBuilder(Activity::class)
->disableOriginalConstructor()
->onlyMethods(['update', 'hasProspect'])
->getMock();
$activity->method('update')->willReturn(true);
$activity->method('hasProspect')->willReturn(true);
$activity->type = Activity::TYPE_CONFERENCE;
$activity->provider = Activity::PROVIDER_TWILIO;
$activity->lead_id = 1;
$activity->user_id = 0;
$activity->id_string = 'test-activity-id';
$activity->user = $user;
$activity->actual_start_time = $actualStartTime;
$activity->uuid = 'c53d8320-f556-4cee-a2f8-5f232f454ca4';
app()->bind(PlaybookResolver::class, function () use ($user) {
$playbook = $this->createMock(Playbook::class);
$playbookResolver = $this->createMock(PlaybookResolver::class);
$playbookResolver->expects($this->once())
->method('resolvePlaybookByUser')
->with($user)
->willReturn($playbook);
return $playbookResolver;
});
$data = $serviceMock->fetchAndAssociateRelatedActivity($activity);
$this->assertInstanceOf(Activity::class, $data);
$this->assertEquals(Activity::TYPE_CONFERENCE, $data->getType());
$this->assertEquals($actualStartTime->getTimestamp(), $data->getActualStartTime()->getTimestamp());
}
public function testFetchAndAssociateRelatedActivitySkipsForTaskBasedPlaybook(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods(['fetchRelatedActivity', 'getPlaybook'])
->getMock();
$user = $this->createMock(User::class);
$playbook = $this->createMock(Playbook::class);
$playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);
$playbook->method('getId')->willReturn(123);
$serviceMock->expects($this->once())
->method('getPlaybook')
->with($user)
->willReturn($playbook);
$serviceMock->expects($this->never())
->method('fetchRelatedActivity');
$activity = $this->getMockBuilder(Activity::class)
->disableOriginalConstructor()
->onlyMethods(['hasProspect', 'getUuid'])
->getMock();
$activity->method('hasProspect')->willReturn(true);
$activity->method('getUuid')->willReturn('c53d8320-f556-4cee-a2f8-5f232f454ca4');
$activity->type = Activity::TYPE_CONFERENCE;
$activity->actual_start_time = \Carbon\Carbon::now();
$activity->user = $user;
$result = $serviceMock->fetchAndAssociateRelatedActivity($activity);
$this->assertNull($result);
}
public function testFetchAndAssociateRelatedActivityReturnsNullForNonConference(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$serviceMock = new Service(
client: $client,
payloadBuilder: $payloadBuilder,
eventDispatcher: $eventDispatcher,
countriesMap: $countriesMap,
prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)
);
$activity = $this->getMockBuilder(Activity::class)
->disableOriginalConstructor()
->getMock();
$activity->type = Activity::TYPE_SOFTPHONE;
$result = $serviceMock->fetchAndAssociateRelatedActivity($activity);
$this->assertNull($result);
}
public function testFetchAndAssociateRelatedActivityReturnsNullWhenNoStartTime(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$serviceMock = new Service(
client: $client,
payloadBuilder: $payloadBuilder,
eventDispatcher: $eventDispatcher,
countriesMap: $countriesMap,
prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)
);
$activity = $this->getMockBuilder(Activity::class)
->disableOriginalConstructor()
->getMock();
$activity->type = Activity::TYPE_CONFERENCE;
$activity->actual_start_time = null;
$activity->scheduled_start_time = null;
$result = $serviceMock->fetchAndAssociateRelatedActivity($activity);
$this->assertNull($result);
}
public function testFetchAndAssociateRelatedActivityReturnsNullWhenNoProspect(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods(['getPlaybook'])
->getMock();
$serviceMock->expects($this->never())
->method('getPlaybook');
$activity = $this->getMockBuilder(Activity::class)
->disableOriginalConstructor()
->onlyMethods(['hasProspect', 'getUuid'])
->getMock();
$activity->method('hasProspect')->willReturn(false);
$activity->method('getUuid')->willReturn('c53d8320-f556-4cee-a2f8-5f232f454ca4');
$activity->type = Activity::TYPE_CONFERENCE;
$activity->actual_start_time = \Carbon\Carbon::now();
$result = $serviceMock->fetchAndAssociateRelatedActivity($activity);
$this->assertNull($result);
}
public function testMatchExactlyByEmail(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods([])
->getMock();
$profile = new Profile();
$profile->setAttribute('id', bin2hex(random_bytes(8)));
$serviceMock->profile = $profile;
$team = $this->createMock(Team::class);
$serviceMock->team = $team;
$data = $serviceMock->matchExactlyByEmail(bin2hex(random_bytes(8)) . '[EMAIL]');
$this->assertEquals(null, $data);
}
public function testMatchDomainFromEmail(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$queryIterator = $this->createMock(QueryIterator::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$config = $this->createMock(Configuration::class);
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('search')
->willReturn($queryIterator);
return $handler;
});
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods(['convertCrmData'])
->getMock();
$profile = new Profile();
$profile->account_fields = 'Field1, Field2, Field3';
$serviceMock->profile = $profile;
$serviceMock->expects($this->once())
->method('convertCrmData')
->willReturn(['test']);
$this->app->bind(QueryBuilder::class, function () {
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->expects($this->once())
->method('buildMatchByDomainQuery')
->with('[EMAIL]')
->willReturn('FIND {[EMAIL]} IN ALL FIELDS RETURNING Account(Id)');
return $queryBuilder;
});
$team = $this->createMock(Team::class);
$serviceMock->team = $team;
$serviceMock->config = $config;
$data = $serviceMock->matchByDomain('[EMAIL]');
$this->assertEquals(['test'], $data);
}
public function testBuildTaskSearchFields(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$service = new Service(
client: $client,
payloadBuilder: $payloadBuilder,
eventDispatcher: $eventDispatcher,
countriesMap: $countriesMap,
prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)
);
$fields = $service->buildTaskSearchFields();
$expectedFields = ['Id', 'WhoId', 'WhatId', 'AccountId'];
$this->assertEquals($expectedFields, $fields);
}
public function testMapCrmObjects(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$service = new Service(
client: $client,
payloadBuilder: $payloadBuilder,
eventDispatcher: $eventDispatcher,
countriesMap: $countriesMap,
prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class),
);
$sampleTask = [
'WhoId' => '003sampleWhoId',
'AccountId' => 'sampleAccountId',
'WhatId' => 'sampleWhatId',
];
$activityData = $service->mapCrmObjects($sampleTask);
$expectedActivityData = [
'contact' => '003sampleWhoId',
'account' => 'sampleAccountId',
'opportunity' => 'sampleWhatId',
];
$this->assertEquals($expectedActivityData, $activityData);
}
public function testGetInstalledAppVersion(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$queryIterator = $this->createMock(QueryIterator::class);
$queryIterator->expects($this->any())
->method('current')->willReturn([
'SubscriberPackageVersion' => [
'MajorVersion' => '1',
'MinorVersion' => '0',
'PatchVersion' => '1',
'BuildNumber' => '0',
],
]);
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('metadata')
->willReturn($queryIterator);
return $handler;
});
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods(array_diff(get_class_methods(Service::class), ['getInstalledAppVersion']))
->getMock();
$version = $serviceMock->getInstalledAppVersion();
$this->assertEquals('1010', $version);
}
public function testSyncProfiles(): void
{
$userToSearch = null;
$team = $this->createMock(Team::class);
$config = $this->createMockedConfiguration();
$config->expects($this->once())
->method('getId')
->willReturn(1);
$salesforceUser = [
'Email' => '[EMAIL]',
'UserPreferencesLightningExperiencePreferred' => true,
'CallCenterId' => '123',
'Id' => '456',
'ProfileId' => '789',
];
app()->bind(QueryBuilder::class, function () use ($userToSearch) {
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->expects($this->once())
->method('buildGetUsersQuery')
->with($userToSearch)
->willReturn('SELECT * FROM Users');
return $queryBuilder;
});
$queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('query')
->with('SELECT * FROM Users')
->willReturn($queryIterator);
return $handler;
});
$user = $this->createMock(User::class);
$user->expects($this->once())
->method('getId')
->willReturn(123);
$this->mockTeamRepository($team, $salesforceUser, $user);
$profileRepository = $this->createMock(ProfileRepository::class);
$profileRepository->expects($this->once())
->method('updateOrCreateProfile')
->with(
$user,
[
'crm_configuration_id' => 1,
'crm_provider_id' => '456',
],
[
'user_id' => 123,
'edition' => Profile::EDITION_LIGHTNING,
'has_external_cti' => true,
'crm_profile_id' => '789',
]
)
->willReturn(new Profile());
$this->app->instance(ProfileRepository::class, $profileRepository);
$serviceMock = $this->getServiceMock();
$serviceMock->team = $team;
$serviceMock->config = $config;
$result = $serviceMock->syncProfiles($userToSearch);
$this->assertNull($result);
}
public function testSyncProfilesEmailIsNull(): void
{
$userToSearch = $this->createMock(User::class);
$salesforceUser = [
'Email' => null,
];
app()->bind(QueryBuilder::class, function () {
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->expects($this->once())
->method('buildGetUsersQuery')
->with(null)
->willReturn('SELECT * FROM Users');
return $queryBuilder;
});
$queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->exactly(2))
->method('query')
->willReturn($queryIterator);
return $handler;
});
$team = $this->createMock(Team::class);
$user = $this->createMock(User::class);
$this->mockTeamRepository($team, $salesforceUser, $user, false);
$config = $this->createMock(Configuration::class);
$serviceMock = $this->getServiceMock();
$serviceMock->team = $team;
$serviceMock->config = $config;
$profile = $serviceMock->syncProfiles(null);
$this->assertNull($profile);
}
public function testSyncProfilesUserToSearchMatchesCurrentUser(): void
{
$userToSearch = $this->createMock(User::class);
$userToSearch->expects($this->once())
->method('getId')
->willReturn(123);
$team = $this->createMock(Team::class);
$config = $this->createMock(Configuration::class);
$config->expects($this->once())
->method('getId')
->willReturn(1);
$salesforceUser = [
'Email' => '[EMAIL]',
'UserPreferencesLightningExperiencePreferred' => true,
'CallCenterId' => '123',
'Id' => '456',
'ProfileId' => '789',
];
app()->bind(QueryBuilder::class, function () use ($userToSearch) {
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->expects($this->once())
->method('buildGetUsersQuery')
->with($userToSearch)
->willReturn('SELECT * FROM Users');
return $queryBuilder;
});
$queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('query')
->with('SELECT * FROM Users')
->willReturn($queryIterator);
return $handler;
});
$user = $this->createMock(User::class);
$user->expects($this->exactly(2))
->method('getId')
->willReturn(123);
$this->mockTeamRepository($team, $salesforceUser, $user);
$profileRepository = $this->createMock(ProfileRepository::class);
$profileRepository->expects($this->once())
->method('updateOrCreateProfile')
->with(
$user,
[
'crm_configuration_id' => 1,
'crm_provider_id' => '456',
],
[
'user_id' => 123,
'edition' => Profile::EDITION_LIGHTNING,
'has_external_cti' => true,
'crm_profile_id' => '789',
]
)
->willReturn(new Profile());
$this->app->instance(ProfileRepository::class, $profileRepository);
$serviceMock = $this->getServiceMock();
$serviceMock->team = $team;
$serviceMock->config = $config;
$profile = $serviceMock->syncProfiles($userToSearch);
$this->assertInstanceOf(Profile::class, $profile);
}
public function testSyncProfilesWithCustomValidation(): void
{
$userToSearch = null;
$team = $this->createMock(Team::class);
$config = $this->createMockedConfiguration();
$config->expects($this->atLeastOnce()) // Changed from once() to atLeastOnce()
->method('getId')
->willReturn(1);
$salesforceUser = [
'Email' => '[EMAIL]',
'UserPreferencesLightningExperiencePreferred' => true,
'CallCenterId' => '123',
'Id' => '456',
'ProfileId' => '789',
'CustomField' => 'CustomValue',
];
$customRules = [
['field' => 'CustomField', 'value' => 'CustomValue'],
];
$this->mockQueryBuilderAndHandler($userToSearch, [$salesforceUser]);
$user = $this->createMock(User::class);
$user->expects($this->once())
->method('getId')
->willReturn(123);
$this->mockTeamRepository($team, $salesforceUser, $user, true, $customRules);
$this->mockProfileRepository($user);
$serviceMock = $this->getServiceMock();
$serviceMock->team = $team;
$serviceMock->config = $config;
$profile = $serviceMock->syncProfiles($userToSearch);
$this->assertNull($profile);
}
public function testSyncProfilesWithCustomValidationFailing(): void
{
$userToSearch = null;
$team = $this->createMock(Team::class);
$config = $this->createMockedConfiguration();
$salesforceUser = [
'Email' => '[EMAIL]',
'UserPreferencesLightningExperiencePreferred' => true,
'CallCenterId' => '123',
'Id' => '456',
'ProfileId' => '789',
'CustomField' => 'WrongValue',
];
$customRules = [
['field' => 'CustomField', 'value' => 'CustomValue'],
];
$this->mockQueryBuilderAndHandler($userToSearch, [$salesforceUser]);
$teamRepository = $this->getMockForAbstractClass(TeamRepository::class, [], '', false, true, true, ['findActiveTeamMemberByEmail', 'getTeamSetting']);
$teamSettings = $this->createMock(TeamSettings::class);
$teamSettings->method('getValueType')
->willReturn('array');
$teamSettings->method('getValue')
->willReturn(json_encode($customRules));
$teamRepository->expects($this->once())
->method('getTeamSetting')
->with($team, 'custom_profile_validation')
->willReturn($teamSettings);
app()->bind(TeamRepository::class, function () use ($teamRepository) {
return $teamRepository;
});
$profileRepository = $this->createMock(ProfileRepository::class);
$profileRepository->expects($this->never())
->method('updateOrCreateProfile');
$this->app->instance(ProfileRepository::class, $profileRepository);
$serviceMock = $this->getServiceMock();
$serviceMock->team = $team;
$serviceMock->config = $config;
$result = $serviceMock->syncProfiles($userToSearch);
$this->assertNull($result);
}
public function testGetContactRolesFromCrm(): void
{
$contactRoles = [
[
'Id' => '1',
'ContactId' => 'Contact1',
'OpportunityId' => 'Opportunity1',
'Opportunity' => ['OwnerId' => 'Owner1'],
'IsPrimary' => true,
'Role' => 'Decision Maker',
],
];
$expectedResponse = [
[
'id' => '1',
'contactId' => 'Contact1',
'opportunityId' => 'Opportunity1',
'ownerId' => 'Owner1',
'isPrimary' => true,
'role' => 'Decision Maker',
],
];
$this->bindQueryIterator($contactRoles);
$serviceMock = $this->getServiceMock();
$data = $serviceMock->getContactRolesFromCrm(now()->subDay());
$this->assertEquals($expectedResponse, $data);
}
public function testGetContactRolesFromCrmNoResult(): void
{
$this->bindQueryIterator([]);
$serviceMock = $this->getServiceMock();
$data = $serviceMock->getContactRolesFromCrm(now()->subDay());
$this->assertEquals([], $data);
}
public function testSyncContactRoles(): void
{
$contactRoles = [
[
'id' => '1',
'contactId' => 'Contact1',
'opportunityId' => 'Opportunity1',
'ownerId' => 'Owner1',
'isPrimary' => true,
'role' => 'Decision Maker',
],
];
app()->bind(ContactRoleRepository::class, function () {
$contactRoleRepository = $this->createMock(ContactRoleRepository::class);
$contactRoleRepository->expects($this->once())
->method('saveContactRoles');
return $contactRoleRepository;
});
$serviceMock = $this->getServiceMock([
'getContactRolesFromCrm',
'syncRemotelyDeletedContactRoles',
'syncContact',
'syncOpportunity',
]);
$config = $this->createMock(Configuration::class);
$hasMany = $this->createMock(HasManyExtended::class);
$hasMany->expects($this->exactly(2))
->method('where')
->willReturn($hasMany);
$hasMany->expects($this->exactly(2))
->method('first')
->willReturn(
$this->createMock(Contact::class),
$this->createMock(Opportunity::class)
);
$config->expects($this->once())
->method('contacts')
->willReturn($hasMany);
$config->expects($this->once())
->method('opportunities')
->willReturn($hasMany);
$serviceMock->config = $config;
$serviceMock->expects($this->once())
->method('getContactRolesFromCrm')
->willReturn($contactRoles);
$serviceMock->expects($this->once())
->method('syncRemotelyDeletedContactRoles');
$data = $serviceMock->syncContactRoles(now()->subDay());
$this->assertEquals(1, $data);
}
public function testSyncRemotelyDeletedContactRoles(): void
{
$contactRoles = [
[
'id' => '1',
'crm_provider_id' => '1',
],
];
app()->bind(QueryHandler::class, function () use ($contactRoles) {
$queryResults = new QueryResults($contactRoles, 1, true, null);
$handler = $this->createMock(QueryHandler::class);
$handler->method('queryDeleted')
->willReturn($queryResults);
return $handler;
});
app()->bind(ContactRoleRepository::class, function () {
$contactRoleRepository = $this->createMock(ContactRoleRepository::class);
$contactRoleRepository->expects($this->once())
->method('deleteContactRoles');
return $contactRoleRepository;
});
$serviceMock = $this->getServiceMock();
$serviceMock->team = $this->createMock(Team::class);
$data = $this->invokePrivateMethod('syncRemotelyDeletedContactRoles', $serviceMock, []);
$this->assertTrue($data);
}
private function bindQueryIterator(array $queryResult): void
{
/** @var Client $client */
$client = $this->createMock(Client::class);
$queryIterator = new QueryIterator(
$client,
new QueryResults($queryResult, 1, true, null)
);
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('query')
->willReturn($queryIterator);
return $handler;
});
}
public static function getOpportunitySortOrderDataProvider(): array
{
return [
'all open recently updated' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED, ['LastModifiedDate DESC', true]],
'all open recently created' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED, ['CreatedDate DESC', true]],
'all open oldest created' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED, ['CreatedDate ASC', true]],
'all recently updated' => [Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED, ['LastModifiedDate DESC', false]],
'default' => ['unknown', ['LastModifiedDate DESC', true]],
];
}
private function createMockedConfiguration(): Configuration
{
$config = $this->createMock(Configuration::class);
$profilesRelation = $this->getMockBuilder(\Illuminate\Database\Eloquent\Relations\HasMany::class)
->disableOriginalConstructor()
->onlyMethods(['get'])
->addMethods(['where', 'first'])
->getMock();
$profilesRelation->method('where')->willReturnSelf();
$profilesRelation->method('get')->willReturn(collect([]));
$profilesRelation->method('first')->willReturn(null);
$config->method('profiles')->willReturn($profilesRelation);
return $config;
}
private function getServiceMock(array $onlyMethods = []): MockObject&Service
{
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$this->createMock(Client::class),
$this->createMock(PayloadBuilder::class),
$this->createMock(Dispatcher::class),
$this->createMock(CountriesMap::class),
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods($onlyMethods)
->getMock();
$serviceMock->profile = $this->createMock(Profile::class);
return $serviceMock;
}
private function mockQueryBuilderAndHandler($userToSearch, $salesforceUsers): void
{
app()->bind(QueryBuilder::class, function () use ($userToSearch) {
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->expects($this->once())
->method('buildGetUsersQuery')
->with($userToSearch)
->willReturn('SELECT * FROM Users');
return $queryBuilder;
});
$queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults($salesforceUsers, count($salesforceUsers), true, null));
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('query')
->willReturn($queryIterator);
return $handler;
});
}
private function mockTeamRepository(
Team $team,
array $salesforceUser,
?User $user = null,
bool $userSearch = true,
array $customRules = []
): void {
$teamRepository = $this->getMockForAbstractClass(TeamRepository::class, [], '', false, true, true, ['findActiveTeamMemberByEmail', 'getTeamSetting']);
if ($userSearch) {
$teamRepository->expects($this->once())
->method('findActiveTeamMemberByEmail')
->with($team, $salesforceUser['Email'])
->willReturn($user);
}
$teamSettings = $this->createMock(TeamSettings::class);
$teamSettings->method('getValueType')
->willReturn('array');
$teamSettings->method('getValue')
->willReturn(json_encode($customRules));
$teamRepository->expects($this->once())
->method('getTeamSetting')
->with($team, 'custom_profile_validation')
->willReturn($teamSettings);
app()->bind(TeamRepository::class, function () use ($teamRepository) {
return $teamRepository;
});
}
private function mockProfileRepository(User $user): void
{
$profileRepository = $this->createMock(ProfileRepository::class);
$profileRepository->expects($this->once())
->method('updateOrCreateProfile')
->with(
$user,
[
'crm_configuration_id' => 1,
'crm_provider_id' => '456',
],
[
'user_id' => 123,
'edition' => Profile::EDITION_LIGHTNING,
'has_external_cti' => true,
'crm_profile_id' => '789',
]
)
->willReturn(new Profile());
$this->app->instance(ProfileRepository::class, $profileRepository);
}
public function testBuildEnhancedNoteDecodesHtmlEntities(): void
{
$service = $this->getServiceMock(['createRecord']);
$profile = new Profile();
$profile->setAttribute('crm_provider_id', 'owner-123');
$service->profile = $profile;
$service->expects($this->exactly(2))
->method('createRecord')
->willReturnOnConsecutiveCalls('note-id-123', 'link-id-456');
$bodyWithEntities = 'Welch's current challenges and Facebook's Club';
$result = $this->invokePrivateMethod('buildEnhancedNote', $service, [
'Test Title',
$bodyWithEntities,
'object-id-789',
]);
$this->assertEquals('note-id-123', $result);
}
public function testBuildEnhancedNoteSanitizesWithoutQuotes(): void
{
$service = $this->getServiceMock(['createRecord']);
$profile = new Profile();
$profile->setAttribute('crm_provider_id', 'owner-456');
$service->profile = $profile;
$service->expects($this->exactly(2))
->method('createRecord')
->willReturnCallback(function ($type, $data) {
if ($type === 'ContentNote') {
$decoded = base64_decode($data['Content']);
$this->assertStringContainsString("Welch's", $decoded);
$this->assertStringNotContainsString(''', $decoded);
$this->assertStringNotContainsString('&#039;', $decoded);
$this->assertStringContainsString('<script>', $decoded);
return 'note-id-456';
}
return 'link-id-789';
});
$bodyWithMixedContent = "Welch's and <script>alert('xss')</script>";
$result = $this->invokePrivateMethod('buildEnhancedNote', $service, [
'Test Title',
$bodyWithMixedContent,
'object-id-123',
]);
$this->assertEquals('note-id-456', $result);
}
public function testBuildEnhancedNoteConvertsLineBreaks(): void
{
$service = $this->getServiceMock(['createRecord']);
$profile = new Profile();
$profile->setAttribute('crm_provider_id', 'owner-789');
$service->profile = $profile;
$service->expects($this->exactly(2))
->method('createRecord')
->willReturnCallback(function ($type, $data) {
if ($type === 'ContentNote') {
$decoded = base64_decode($data['Content']);
$this->assertStringContainsString('<br>', $decoded);
$this->assertStringNotContainsString('<br />', $decoded);
return 'note-id-789';
}
return 'link-id-012';
});
$bodyWithLineBreaks = "Line 1\nLine 2\nLine 3";
$result = $this->invokePrivateMethod('buildEnhancedNote', $service, [
'Test Title',
$bodyWithLineBreaks,
'object-id-456',
]);
$this->assertEquals('note-id-789', $result);
}
public function testBuildEnhancedNoteHandlesComplexScenario(): void
{
$service = $this->getServiceMock(['createRecord']);
$profile = new Profile();
$profile->setAttribute('crm_provider_id', 'owner-complex');
$service->profile = $profile;
$service->expects($this->exactly(2))
->method('createRecord')
->willReturnCallback(function ($type, $data) {
if ($type === 'ContentNote') {
$decoded = base64_decode($data['Content']);
$this->assertStringContainsString("Welch's", $decoded);
$this->assertStringContainsString("Facebook's Club", $decoded);
$this->assertStringContainsString("Arctics'", $decoded);
$this->assertStringNotContainsString(''', $decoded);
$this->assertStringNotContainsString('&#039;', $decoded);
$this->assertStringContainsString('<br>', $decoded);
$this->assertStringContainsString('<', $decoded);
$this->assertStringContainsString('>', $decoded);
return 'note-complex';
}
return 'link-complex';
});
$complexBody = "Summary:\n---------\nThe call focused on understanding Welch's current challenges and exploring how Arctics' Revenue Growth Management solutions could support their strategic goals.\n\n• John SMith discussed his role as a category advisor for Google and Facebook's Club, emphasizing the importance of market research and advising on product assortment.\n• Madona introduced Arctics' Virtual Shoppers AI, which simulates consumer <behavior> to optimize pricing and promotional strategies.";
$result = $this->invokePrivateMethod('buildEnhancedNote', $service, [
'Jiminny Transcription Summary',
$complexBody,
'task-id-001',
]);
$this->assertEquals('note-complex', $result);
}
public function testSyncRemotelyDeletedObjectsWithErrorHandlingSuccess(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$prospectPhotoPathService,
])
->onlyMethods(['syncRemotelyDeletedObjects'])
->getMock();
// Mock team
$team = $this->createMock(Team::class);
$team->method('getUuid')->willReturn('team-uuid-123');
$serviceMock->team = $team;
// Expect syncRemotelyDeletedObjects to be called once and succeed
$serviceMock->expects($this->once())
->method('syncRemotelyDeletedObjects')
->with(\Jiminny\Enums\CrmObject::ACCOUNT);
// Call the protected method using reflection
$reflection = new \ReflectionClass($serviceMock);
$method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');
$method->setAccessible(true);
// Should not throw any exceptions
$method->invoke($serviceMock, \Jiminny\Enums\CrmObject::ACCOUNT);
$this->assertTrue(true); // Test completed successfully
}
public function testSyncRemotelyDeletedObjectsWithErrorHandlingFailure(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$prospectPhotoPathService,
])
->onlyMethods(['syncRemotelyDeletedObjects'])
->getMock();
// Mock team
$team = $this->createMock(Team::class);
$team->method('getUuid')->willReturn('team-uuid-456');
$serviceMock->team = $team;
// Mock logger to verify warning is logged
$logger = $this->createMock(\Psr\Log\LoggerInterface::class);
// Use reflection to set the protected logger property
$reflection = new \ReflectionClass($serviceMock);
$loggerProperty = $reflection->getProperty('logger');
$loggerProperty->setAccessible(true);
$loggerProperty->setValue($serviceMock, $logger);
$exception = new \Exception('Sync failed due to API error');
// Expect syncRemotelyDeletedObjects to throw an exception
$serviceMock->expects($this->once())
->method('syncRemotelyDeletedObjects')
->with(\Jiminny\Enums\CrmObject::CONTACT)
->willThrowException($exception);
// Expect warning to be logged with correct message and parameters
$logger->expects($this->once())
->method('warning')
->with(
'[Salesforce] Remotely deleted objects sync failed',
[
'objectType' => 'contact',
'teamId' => 'team-uuid-456',
'reason' => 'Sync failed due to API error',
]
);
// Call the protected method using reflection
$reflection = new \ReflectionClass($serviceMock);
$method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');
$method->setAccessible(true);
// Should not re-throw the exception, just log it
$method->invoke($serviceMock, \Jiminny\Enums\CrmObject::CONTACT);
$this->assertTrue(true); // Test completed successfully
}
public function testSyncRemotelyDeletedObjectsWithErrorHandlingWithLogParams(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$prospectPhotoPathService,
])
->onlyMethods(['syncRemotelyDeletedObjects'])
->getMock();
// Mock team
$team = $this->createMock(Team::class);
$team->method('getUuid')->willReturn('team-uuid-789');
$serviceMock->team = $team;
// Mock logger to verify warning is logged
$logger = $this->createMock(\Psr\Log\LoggerInterface::class);
// Use reflection to set the protected logger property
$loggerReflection = new \ReflectionClass($serviceMock);
$loggerProperty = $loggerReflection->getProperty('logger');
$loggerProperty->setAccessible(true);
$loggerProperty->setValue($serviceMock, $logger);
$exception = new \Exception('Network timeout');
// Expect syncRemotelyDeletedObjects to throw an exception
$serviceMock->expects($this->once())
->method('syncRemotelyDeletedObjects')
->with(\Jiminny\Enums\CrmObject::OPPORTUNITY)
->willThrowException($exception);
// Additional log parameters
$logParams = [
'syncType' => 'full',
'batchSize' => 100,
];
// Expect warning to be logged with merged parameters
$logger->expects($this->once())
->method('warning')
->with(
'[Salesforce] Remotely deleted objects sync failed',
[
'objectType' => 'opportunity',
'teamId' => 'team-uuid-789',
'reason' => 'Network timeout',
'syncType' => 'full',
'batchSize' => 100,
]
);
// Call the protected method using reflection
$reflection = new \ReflectionClass($serviceMock);
$method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');
...
|
[{"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-20963-fix-import-on-deleted-entity, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.09607713,"height":0.025538707},"on_screen":true,"help_text":"Git Branch: JY-20963-fix-import-on-deleted-entity","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.85638297,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"ServiceTest","depth":6,"bounds":{"left":0.87167555,"top":0.019952115,"width":0.043882977,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Rerun 'PHPUnit: ServiceTest'","depth":6,"bounds":{"left":0.9155585,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'ServiceTest'","depth":6,"bounds":{"left":0.9268617,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Stop 'ServiceTest'","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":"More Actions","depth":6,"bounds":{"left":0.9494681,"top":0.019952115,"width":0.011303191,"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":"Built-in Preview","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":"Chrome","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":"Firefox","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":"Safari","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":"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":"4","depth":4,"bounds":{"left":0.35472074,"top":0.10055866,"width":0.007978723,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"32","depth":4,"bounds":{"left":0.36469415,"top":0.10055866,"width":0.010305851,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"168","depth":4,"bounds":{"left":0.37699467,"top":0.10055866,"width":0.011968086,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"bounds":{"left":0.39095744,"top":0.10055866,"width":0.00731383,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"28","depth":4,"bounds":{"left":0.40026596,"top":0.10055866,"width":0.009973404,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.4119016,"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.4192154,"top":0.09896249,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Services\\Crm\\Salesforce;\n\nuse Illuminate\\Database\\Eloquent\\Relations\\HasMany;\nuse Illuminate\\Events\\Dispatcher;\nuse Illuminate\\Support\\Facades\\Cache;\nuse Jiminny\\Component\\Country\\CountriesMap;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Enums\\CrmObject;\nuse Jiminny\\Exceptions\\InvalidArgumentException;\nuse Jiminny\\Exceptions\\NoResultsException;\nuse Jiminny\\Integrations\\PlaybookResolver;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Crm\\Configuration;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\TeamSettings;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\ContactRoleRepository;\nuse Jiminny\\Repositories\\Crm\\FieldDataRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\Salesforce\\Client;\nuse Jiminny\\Services\\Crm\\Salesforce\\PayloadBuilder;\nuse Jiminny\\Services\\Crm\\Salesforce\\QueryBuilder;\nuse Jiminny\\Services\\Crm\\Salesforce\\QueryHandler;\nuse Jiminny\\Services\\Crm\\Salesforce\\QueryIterator;\nuse Jiminny\\Services\\Crm\\Salesforce\\QueryResults;\nuse Jiminny\\Services\\Crm\\Salesforce\\Service;\nuse PHPUnit\\Framework\\MockObject\\MockObject;\nuse Tests\\TestCase;\nuse Tests\\Unit\\Traits\\TestPrivateMethod;\n\nclass ServiceTest extends TestCase\n{\n use TestPrivateMethod;\n\n public function testFetchAndAssociateRelatedActivity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $payloadBuilder->method('addCustomLogicFieldsPayload')\n ->willReturnCallback(function ($activity, $payload) {\n return $payload;\n });\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods(['fetchRelatedActivity', 'getPlaybook', 'getPlaybookCategory', 'updateRecord'])\n ->getMock();\n\n $serviceMock->expects($this->once())\n ->method('fetchRelatedActivity')\n ->willReturn([\n 'Id' => 'testId',\n 'Type' => null,\n 'OwnerId' => 'testerUser',\n 'Description' => 'Test description',\n ]);\n\n $user = $this->createMock(User::class);\n $team = $this->createMock(Team::class);\n $user->method('getAttribute')->with('team')->willReturn($team);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityField')->willReturn(null);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_EVENT);\n\n $serviceMock->expects($this->once())\n ->method('getPlaybook')\n ->with($user)\n ->willReturn($playbook);\n\n $serviceMock->expects($this->never())\n ->method('getPlaybookCategory');\n\n $serviceMock->expects($this->never())\n ->method('updateRecord');\n\n $fieldDataRepository = $this->createMock(FieldDataRepository::class);\n $fieldDataRepository->method('getActivityFieldData')->willReturn(collect([]));\n app()->instance(FieldDataRepository::class, $fieldDataRepository);\n\n $config = $this->createMock(Configuration::class);\n $profilesRelation = $this->getMockBuilder(\\Illuminate\\Database\\Eloquent\\Relations\\HasMany::class)\n ->disableOriginalConstructor()\n ->onlyMethods(['get'])\n ->addMethods(['where'])\n ->getMock();\n $profilesRelation->method('where')->willReturnSelf();\n $profilesRelation->method('get')->willReturn(collect([]));\n $config->method('profiles')->willReturn($profilesRelation);\n\n $serviceMock->config = $config;\n $serviceMock->profile = null;\n\n $actualStartTime = \\Carbon\\Carbon::now();\n\n $activity = $this->getMockBuilder(Activity::class)\n ->disableOriginalConstructor()\n ->onlyMethods(['update', 'hasProspect'])\n ->getMock();\n\n $activity->method('update')->willReturn(true);\n $activity->method('hasProspect')->willReturn(true);\n\n $activity->type = Activity::TYPE_CONFERENCE;\n $activity->provider = Activity::PROVIDER_TWILIO;\n $activity->lead_id = 1;\n $activity->user_id = 0;\n $activity->id_string = 'test-activity-id';\n $activity->user = $user;\n\n $activity->actual_start_time = $actualStartTime;\n $activity->uuid = 'c53d8320-f556-4cee-a2f8-5f232f454ca4';\n\n app()->bind(PlaybookResolver::class, function () use ($user) {\n $playbook = $this->createMock(Playbook::class);\n $playbookResolver = $this->createMock(PlaybookResolver::class);\n $playbookResolver->expects($this->once())\n ->method('resolvePlaybookByUser')\n ->with($user)\n ->willReturn($playbook);\n\n return $playbookResolver;\n });\n\n $data = $serviceMock->fetchAndAssociateRelatedActivity($activity);\n\n $this->assertInstanceOf(Activity::class, $data);\n $this->assertEquals(Activity::TYPE_CONFERENCE, $data->getType());\n $this->assertEquals($actualStartTime->getTimestamp(), $data->getActualStartTime()->getTimestamp());\n }\n\n public function testFetchAndAssociateRelatedActivitySkipsForTaskBasedPlaybook(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods(['fetchRelatedActivity', 'getPlaybook'])\n ->getMock();\n\n $user = $this->createMock(User::class);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n $playbook->method('getId')->willReturn(123);\n\n $serviceMock->expects($this->once())\n ->method('getPlaybook')\n ->with($user)\n ->willReturn($playbook);\n\n $serviceMock->expects($this->never())\n ->method('fetchRelatedActivity');\n\n $activity = $this->getMockBuilder(Activity::class)\n ->disableOriginalConstructor()\n ->onlyMethods(['hasProspect', 'getUuid'])\n ->getMock();\n\n $activity->method('hasProspect')->willReturn(true);\n $activity->method('getUuid')->willReturn('c53d8320-f556-4cee-a2f8-5f232f454ca4');\n $activity->type = Activity::TYPE_CONFERENCE;\n $activity->actual_start_time = \\Carbon\\Carbon::now();\n $activity->user = $user;\n\n $result = $serviceMock->fetchAndAssociateRelatedActivity($activity);\n\n $this->assertNull($result);\n }\n\n public function testFetchAndAssociateRelatedActivityReturnsNullForNonConference(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $serviceMock = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)\n );\n\n $activity = $this->getMockBuilder(Activity::class)\n ->disableOriginalConstructor()\n ->getMock();\n\n $activity->type = Activity::TYPE_SOFTPHONE;\n\n $result = $serviceMock->fetchAndAssociateRelatedActivity($activity);\n\n $this->assertNull($result);\n }\n\n public function testFetchAndAssociateRelatedActivityReturnsNullWhenNoStartTime(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $serviceMock = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)\n );\n\n\n $activity = $this->getMockBuilder(Activity::class)\n ->disableOriginalConstructor()\n ->getMock();\n\n $activity->type = Activity::TYPE_CONFERENCE;\n $activity->actual_start_time = null;\n $activity->scheduled_start_time = null;\n\n $result = $serviceMock->fetchAndAssociateRelatedActivity($activity);\n\n $this->assertNull($result);\n }\n\n public function testFetchAndAssociateRelatedActivityReturnsNullWhenNoProspect(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods(['getPlaybook'])\n ->getMock();\n\n $serviceMock->expects($this->never())\n ->method('getPlaybook');\n\n $activity = $this->getMockBuilder(Activity::class)\n ->disableOriginalConstructor()\n ->onlyMethods(['hasProspect', 'getUuid'])\n ->getMock();\n\n $activity->method('hasProspect')->willReturn(false);\n $activity->method('getUuid')->willReturn('c53d8320-f556-4cee-a2f8-5f232f454ca4');\n $activity->type = Activity::TYPE_CONFERENCE;\n $activity->actual_start_time = \\Carbon\\Carbon::now();\n\n $result = $serviceMock->fetchAndAssociateRelatedActivity($activity);\n\n $this->assertNull($result);\n }\n\n public function testMatchExactlyByEmail(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods([])\n ->getMock();\n\n $profile = new Profile();\n $profile->setAttribute('id', bin2hex(random_bytes(8)));\n $serviceMock->profile = $profile;\n\n $team = $this->createMock(Team::class);\n $serviceMock->team = $team;\n\n $data = $serviceMock->matchExactlyByEmail(bin2hex(random_bytes(8)) . 'test_email@testserver.com');\n\n $this->assertEquals(null, $data);\n }\n\n public function testMatchDomainFromEmail(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $queryIterator = $this->createMock(QueryIterator::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $config = $this->createMock(Configuration::class);\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('search')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods(['convertCrmData'])\n ->getMock();\n\n $profile = new Profile();\n $profile->account_fields = 'Field1, Field2, Field3';\n $serviceMock->profile = $profile;\n\n $serviceMock->expects($this->once())\n ->method('convertCrmData')\n ->willReturn(['test']);\n\n $this->app->bind(QueryBuilder::class, function () {\n $queryBuilder = $this->createMock(QueryBuilder::class);\n $queryBuilder->expects($this->once())\n ->method('buildMatchByDomainQuery')\n ->with('test_email@testserver.com')\n ->willReturn('FIND {test_email@testserver.com} IN ALL FIELDS RETURNING Account(Id)');\n\n return $queryBuilder;\n });\n\n $team = $this->createMock(Team::class);\n\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n\n $data = $serviceMock->matchByDomain('test_email@testserver.com');\n\n $this->assertEquals(['test'], $data);\n }\n\n public function testBuildTaskSearchFields(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)\n );\n\n $fields = $service->buildTaskSearchFields();\n\n $expectedFields = ['Id', 'WhoId', 'WhatId', 'AccountId'];\n\n $this->assertEquals($expectedFields, $fields);\n }\n\n public function testMapCrmObjects(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class),\n );\n\n $sampleTask = [\n 'WhoId' => '003sampleWhoId',\n 'AccountId' => 'sampleAccountId',\n 'WhatId' => 'sampleWhatId',\n ];\n\n $activityData = $service->mapCrmObjects($sampleTask);\n\n $expectedActivityData = [\n 'contact' => '003sampleWhoId',\n 'account' => 'sampleAccountId',\n 'opportunity' => 'sampleWhatId',\n ];\n\n $this->assertEquals($expectedActivityData, $activityData);\n }\n\n public function testGetInstalledAppVersion(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n $queryIterator = $this->createMock(QueryIterator::class);\n $queryIterator->expects($this->any())\n ->method('current')->willReturn([\n 'SubscriberPackageVersion' => [\n 'MajorVersion' => '1',\n 'MinorVersion' => '0',\n 'PatchVersion' => '1',\n 'BuildNumber' => '0',\n ],\n ]);\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('metadata')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods(array_diff(get_class_methods(Service::class), ['getInstalledAppVersion']))\n ->getMock();\n\n $version = $serviceMock->getInstalledAppVersion();\n\n $this->assertEquals('1010', $version);\n }\n\n public function testSyncProfiles(): void\n {\n $userToSearch = null;\n\n $team = $this->createMock(Team::class);\n $config = $this->createMockedConfiguration();\n $config->expects($this->once())\n ->method('getId')\n ->willReturn(1);\n\n $salesforceUser = [\n 'Email' => 'test@example.com',\n 'UserPreferencesLightningExperiencePreferred' => true,\n 'CallCenterId' => '123',\n 'Id' => '456',\n 'ProfileId' => '789',\n ];\n\n app()->bind(QueryBuilder::class, function () use ($userToSearch) {\n $queryBuilder = $this->createMock(QueryBuilder::class);\n $queryBuilder->expects($this->once())\n ->method('buildGetUsersQuery')\n ->with($userToSearch)\n ->willReturn('SELECT * FROM Users');\n\n return $queryBuilder;\n });\n\n $queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('query')\n ->with('SELECT * FROM Users')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n\n $user = $this->createMock(User::class);\n $user->expects($this->once())\n ->method('getId')\n ->willReturn(123);\n\n $this->mockTeamRepository($team, $salesforceUser, $user);\n\n $profileRepository = $this->createMock(ProfileRepository::class);\n $profileRepository->expects($this->once())\n ->method('updateOrCreateProfile')\n ->with(\n $user,\n [\n 'crm_configuration_id' => 1,\n 'crm_provider_id' => '456',\n ],\n [\n 'user_id' => 123,\n 'edition' => Profile::EDITION_LIGHTNING,\n 'has_external_cti' => true,\n 'crm_profile_id' => '789',\n ]\n )\n ->willReturn(new Profile());\n\n $this->app->instance(ProfileRepository::class, $profileRepository);\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n $result = $serviceMock->syncProfiles($userToSearch);\n\n $this->assertNull($result);\n }\n\n public function testSyncProfilesEmailIsNull(): void\n {\n $userToSearch = $this->createMock(User::class);\n\n $salesforceUser = [\n 'Email' => null,\n ];\n\n app()->bind(QueryBuilder::class, function () {\n $queryBuilder = $this->createMock(QueryBuilder::class);\n $queryBuilder->expects($this->once())\n ->method('buildGetUsersQuery')\n ->with(null)\n ->willReturn('SELECT * FROM Users');\n\n return $queryBuilder;\n });\n\n $queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->exactly(2))\n ->method('query')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n\n $team = $this->createMock(Team::class);\n $user = $this->createMock(User::class);\n $this->mockTeamRepository($team, $salesforceUser, $user, false);\n\n $config = $this->createMock(Configuration::class);\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n\n $profile = $serviceMock->syncProfiles(null);\n\n $this->assertNull($profile);\n }\n\n public function testSyncProfilesUserToSearchMatchesCurrentUser(): void\n {\n $userToSearch = $this->createMock(User::class);\n $userToSearch->expects($this->once())\n ->method('getId')\n ->willReturn(123);\n\n $team = $this->createMock(Team::class);\n $config = $this->createMock(Configuration::class);\n $config->expects($this->once())\n ->method('getId')\n ->willReturn(1);\n\n $salesforceUser = [\n 'Email' => 'test@example.com',\n 'UserPreferencesLightningExperiencePreferred' => true,\n 'CallCenterId' => '123',\n 'Id' => '456',\n 'ProfileId' => '789',\n ];\n\n app()->bind(QueryBuilder::class, function () use ($userToSearch) {\n $queryBuilder = $this->createMock(QueryBuilder::class);\n $queryBuilder->expects($this->once())\n ->method('buildGetUsersQuery')\n ->with($userToSearch)\n ->willReturn('SELECT * FROM Users');\n\n return $queryBuilder;\n });\n\n $queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('query')\n ->with('SELECT * FROM Users')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n\n $user = $this->createMock(User::class);\n $user->expects($this->exactly(2))\n ->method('getId')\n ->willReturn(123);\n\n $this->mockTeamRepository($team, $salesforceUser, $user);\n\n $profileRepository = $this->createMock(ProfileRepository::class);\n $profileRepository->expects($this->once())\n ->method('updateOrCreateProfile')\n ->with(\n $user,\n [\n 'crm_configuration_id' => 1,\n 'crm_provider_id' => '456',\n ],\n [\n 'user_id' => 123,\n 'edition' => Profile::EDITION_LIGHTNING,\n 'has_external_cti' => true,\n 'crm_profile_id' => '789',\n ]\n )\n ->willReturn(new Profile());\n\n $this->app->instance(ProfileRepository::class, $profileRepository);\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n $profile = $serviceMock->syncProfiles($userToSearch);\n\n $this->assertInstanceOf(Profile::class, $profile);\n }\n\n public function testSyncProfilesWithCustomValidation(): void\n {\n $userToSearch = null;\n\n $team = $this->createMock(Team::class);\n $config = $this->createMockedConfiguration();\n $config->expects($this->atLeastOnce()) // Changed from once() to atLeastOnce()\n ->method('getId')\n ->willReturn(1);\n\n $salesforceUser = [\n 'Email' => 'test@example.com',\n 'UserPreferencesLightningExperiencePreferred' => true,\n 'CallCenterId' => '123',\n 'Id' => '456',\n 'ProfileId' => '789',\n 'CustomField' => 'CustomValue',\n ];\n\n $customRules = [\n ['field' => 'CustomField', 'value' => 'CustomValue'],\n ];\n\n $this->mockQueryBuilderAndHandler($userToSearch, [$salesforceUser]);\n\n $user = $this->createMock(User::class);\n $user->expects($this->once())\n ->method('getId')\n ->willReturn(123);\n\n $this->mockTeamRepository($team, $salesforceUser, $user, true, $customRules);\n\n $this->mockProfileRepository($user);\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n $profile = $serviceMock->syncProfiles($userToSearch);\n\n $this->assertNull($profile);\n }\n\n public function testSyncProfilesWithCustomValidationFailing(): void\n {\n $userToSearch = null;\n\n $team = $this->createMock(Team::class);\n $config = $this->createMockedConfiguration();\n\n $salesforceUser = [\n 'Email' => 'test@example.com',\n 'UserPreferencesLightningExperiencePreferred' => true,\n 'CallCenterId' => '123',\n 'Id' => '456',\n 'ProfileId' => '789',\n 'CustomField' => 'WrongValue',\n ];\n\n $customRules = [\n ['field' => 'CustomField', 'value' => 'CustomValue'],\n ];\n\n $this->mockQueryBuilderAndHandler($userToSearch, [$salesforceUser]);\n\n $teamRepository = $this->getMockForAbstractClass(TeamRepository::class, [], '', false, true, true, ['findActiveTeamMemberByEmail', 'getTeamSetting']);\n\n $teamSettings = $this->createMock(TeamSettings::class);\n $teamSettings->method('getValueType')\n ->willReturn('array');\n\n $teamSettings->method('getValue')\n ->willReturn(json_encode($customRules));\n\n $teamRepository->expects($this->once())\n ->method('getTeamSetting')\n ->with($team, 'custom_profile_validation')\n ->willReturn($teamSettings);\n\n app()->bind(TeamRepository::class, function () use ($teamRepository) {\n return $teamRepository;\n });\n\n $profileRepository = $this->createMock(ProfileRepository::class);\n $profileRepository->expects($this->never())\n ->method('updateOrCreateProfile');\n\n $this->app->instance(ProfileRepository::class, $profileRepository);\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n $result = $serviceMock->syncProfiles($userToSearch);\n\n $this->assertNull($result);\n }\n\n public function testGetContactRolesFromCrm(): void\n {\n $contactRoles = [\n [\n 'Id' => '1',\n 'ContactId' => 'Contact1',\n 'OpportunityId' => 'Opportunity1',\n 'Opportunity' => ['OwnerId' => 'Owner1'],\n 'IsPrimary' => true,\n 'Role' => 'Decision Maker',\n ],\n ];\n\n $expectedResponse = [\n [\n 'id' => '1',\n 'contactId' => 'Contact1',\n 'opportunityId' => 'Opportunity1',\n 'ownerId' => 'Owner1',\n 'isPrimary' => true,\n 'role' => 'Decision Maker',\n ],\n ];\n\n $this->bindQueryIterator($contactRoles);\n\n $serviceMock = $this->getServiceMock();\n\n $data = $serviceMock->getContactRolesFromCrm(now()->subDay());\n\n $this->assertEquals($expectedResponse, $data);\n }\n\n public function testGetContactRolesFromCrmNoResult(): void\n {\n $this->bindQueryIterator([]);\n\n $serviceMock = $this->getServiceMock();\n\n $data = $serviceMock->getContactRolesFromCrm(now()->subDay());\n\n $this->assertEquals([], $data);\n }\n\n public function testSyncContactRoles(): void\n {\n $contactRoles = [\n [\n 'id' => '1',\n 'contactId' => 'Contact1',\n 'opportunityId' => 'Opportunity1',\n 'ownerId' => 'Owner1',\n 'isPrimary' => true,\n 'role' => 'Decision Maker',\n ],\n ];\n\n app()->bind(ContactRoleRepository::class, function () {\n $contactRoleRepository = $this->createMock(ContactRoleRepository::class);\n $contactRoleRepository->expects($this->once())\n ->method('saveContactRoles');\n\n return $contactRoleRepository;\n });\n\n $serviceMock = $this->getServiceMock([\n 'getContactRolesFromCrm',\n 'syncRemotelyDeletedContactRoles',\n 'syncContact',\n 'syncOpportunity',\n ]);\n\n $config = $this->createMock(Configuration::class);\n $hasMany = $this->createMock(HasManyExtended::class);\n $hasMany->expects($this->exactly(2))\n ->method('where')\n ->willReturn($hasMany);\n\n $hasMany->expects($this->exactly(2))\n ->method('first')\n ->willReturn(\n $this->createMock(Contact::class),\n $this->createMock(Opportunity::class)\n );\n\n $config->expects($this->once())\n ->method('contacts')\n ->willReturn($hasMany);\n $config->expects($this->once())\n ->method('opportunities')\n ->willReturn($hasMany);\n\n $serviceMock->config = $config;\n\n $serviceMock->expects($this->once())\n ->method('getContactRolesFromCrm')\n ->willReturn($contactRoles);\n\n $serviceMock->expects($this->once())\n ->method('syncRemotelyDeletedContactRoles');\n\n $data = $serviceMock->syncContactRoles(now()->subDay());\n\n $this->assertEquals(1, $data);\n }\n\n public function testSyncRemotelyDeletedContactRoles(): void\n {\n $contactRoles = [\n [\n 'id' => '1',\n 'crm_provider_id' => '1',\n ],\n ];\n\n app()->bind(QueryHandler::class, function () use ($contactRoles) {\n $queryResults = new QueryResults($contactRoles, 1, true, null);\n\n $handler = $this->createMock(QueryHandler::class);\n $handler->method('queryDeleted')\n ->willReturn($queryResults);\n\n return $handler;\n });\n\n app()->bind(ContactRoleRepository::class, function () {\n $contactRoleRepository = $this->createMock(ContactRoleRepository::class);\n $contactRoleRepository->expects($this->once())\n ->method('deleteContactRoles');\n\n return $contactRoleRepository;\n });\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $this->createMock(Team::class);\n\n $data = $this->invokePrivateMethod('syncRemotelyDeletedContactRoles', $serviceMock, []);\n\n $this->assertTrue($data);\n }\n\n private function bindQueryIterator(array $queryResult): void\n {\n /** @var Client $client */\n $client = $this->createMock(Client::class);\n $queryIterator = new QueryIterator(\n $client,\n new QueryResults($queryResult, 1, true, null)\n );\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('query')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n }\n\n public static function getOpportunitySortOrderDataProvider(): array\n {\n return [\n 'all open recently updated' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED, ['LastModifiedDate DESC', true]],\n 'all open recently created' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED, ['CreatedDate DESC', true]],\n 'all open oldest created' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED, ['CreatedDate ASC', true]],\n 'all recently updated' => [Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED, ['LastModifiedDate DESC', false]],\n 'default' => ['unknown', ['LastModifiedDate DESC', true]],\n ];\n }\n\n private function createMockedConfiguration(): Configuration\n {\n $config = $this->createMock(Configuration::class);\n $profilesRelation = $this->getMockBuilder(\\Illuminate\\Database\\Eloquent\\Relations\\HasMany::class)\n ->disableOriginalConstructor()\n ->onlyMethods(['get'])\n ->addMethods(['where', 'first'])\n ->getMock();\n $profilesRelation->method('where')->willReturnSelf();\n $profilesRelation->method('get')->willReturn(collect([]));\n $profilesRelation->method('first')->willReturn(null);\n $config->method('profiles')->willReturn($profilesRelation);\n\n return $config;\n }\n\n private function getServiceMock(array $onlyMethods = []): MockObject&Service\n {\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $this->createMock(Client::class),\n $this->createMock(PayloadBuilder::class),\n $this->createMock(Dispatcher::class),\n $this->createMock(CountriesMap::class),\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods($onlyMethods)\n ->getMock();\n\n $serviceMock->profile = $this->createMock(Profile::class);\n\n return $serviceMock;\n }\n\n private function mockQueryBuilderAndHandler($userToSearch, $salesforceUsers): void\n {\n app()->bind(QueryBuilder::class, function () use ($userToSearch) {\n $queryBuilder = $this->createMock(QueryBuilder::class);\n $queryBuilder->expects($this->once())\n ->method('buildGetUsersQuery')\n ->with($userToSearch)\n ->willReturn('SELECT * FROM Users');\n\n return $queryBuilder;\n });\n\n $queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults($salesforceUsers, count($salesforceUsers), true, null));\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('query')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n }\n\n private function mockTeamRepository(\n Team $team,\n array $salesforceUser,\n ?User $user = null,\n bool $userSearch = true,\n array $customRules = []\n ): void {\n $teamRepository = $this->getMockForAbstractClass(TeamRepository::class, [], '', false, true, true, ['findActiveTeamMemberByEmail', 'getTeamSetting']);\n\n if ($userSearch) {\n $teamRepository->expects($this->once())\n ->method('findActiveTeamMemberByEmail')\n ->with($team, $salesforceUser['Email'])\n ->willReturn($user);\n }\n\n $teamSettings = $this->createMock(TeamSettings::class);\n $teamSettings->method('getValueType')\n ->willReturn('array');\n\n $teamSettings->method('getValue')\n ->willReturn(json_encode($customRules));\n\n $teamRepository->expects($this->once())\n ->method('getTeamSetting')\n ->with($team, 'custom_profile_validation')\n ->willReturn($teamSettings);\n\n app()->bind(TeamRepository::class, function () use ($teamRepository) {\n return $teamRepository;\n });\n }\n\n private function mockProfileRepository(User $user): void\n {\n $profileRepository = $this->createMock(ProfileRepository::class);\n $profileRepository->expects($this->once())\n ->method('updateOrCreateProfile')\n ->with(\n $user,\n [\n 'crm_configuration_id' => 1,\n 'crm_provider_id' => '456',\n ],\n [\n 'user_id' => 123,\n 'edition' => Profile::EDITION_LIGHTNING,\n 'has_external_cti' => true,\n 'crm_profile_id' => '789',\n ]\n )\n ->willReturn(new Profile());\n\n $this->app->instance(ProfileRepository::class, $profileRepository);\n }\n\n public function testBuildEnhancedNoteDecodesHtmlEntities(): void\n {\n $service = $this->getServiceMock(['createRecord']);\n\n $profile = new Profile();\n $profile->setAttribute('crm_provider_id', 'owner-123');\n $service->profile = $profile;\n\n $service->expects($this->exactly(2))\n ->method('createRecord')\n ->willReturnOnConsecutiveCalls('note-id-123', 'link-id-456');\n\n $bodyWithEntities = 'Welch's current challenges and Facebook's Club';\n\n $result = $this->invokePrivateMethod('buildEnhancedNote', $service, [\n 'Test Title',\n $bodyWithEntities,\n 'object-id-789',\n ]);\n\n $this->assertEquals('note-id-123', $result);\n }\n\n public function testBuildEnhancedNoteSanitizesWithoutQuotes(): void\n {\n $service = $this->getServiceMock(['createRecord']);\n\n $profile = new Profile();\n $profile->setAttribute('crm_provider_id', 'owner-456');\n $service->profile = $profile;\n\n $service->expects($this->exactly(2))\n ->method('createRecord')\n ->willReturnCallback(function ($type, $data) {\n if ($type === 'ContentNote') {\n $decoded = base64_decode($data['Content']);\n $this->assertStringContainsString(\"Welch's\", $decoded);\n $this->assertStringNotContainsString(''', $decoded);\n $this->assertStringNotContainsString('&#039;', $decoded);\n $this->assertStringContainsString('<script>', $decoded);\n\n return 'note-id-456';\n }\n\n return 'link-id-789';\n });\n\n $bodyWithMixedContent = \"Welch's and <script>alert('xss')</script>\";\n\n $result = $this->invokePrivateMethod('buildEnhancedNote', $service, [\n 'Test Title',\n $bodyWithMixedContent,\n 'object-id-123',\n ]);\n\n $this->assertEquals('note-id-456', $result);\n }\n\n public function testBuildEnhancedNoteConvertsLineBreaks(): void\n {\n $service = $this->getServiceMock(['createRecord']);\n\n $profile = new Profile();\n $profile->setAttribute('crm_provider_id', 'owner-789');\n $service->profile = $profile;\n\n $service->expects($this->exactly(2))\n ->method('createRecord')\n ->willReturnCallback(function ($type, $data) {\n if ($type === 'ContentNote') {\n $decoded = base64_decode($data['Content']);\n $this->assertStringContainsString('<br>', $decoded);\n $this->assertStringNotContainsString('<br />', $decoded);\n\n return 'note-id-789';\n }\n\n return 'link-id-012';\n });\n\n $bodyWithLineBreaks = \"Line 1\\nLine 2\\nLine 3\";\n\n $result = $this->invokePrivateMethod('buildEnhancedNote', $service, [\n 'Test Title',\n $bodyWithLineBreaks,\n 'object-id-456',\n ]);\n\n $this->assertEquals('note-id-789', $result);\n }\n\n public function testBuildEnhancedNoteHandlesComplexScenario(): void\n {\n $service = $this->getServiceMock(['createRecord']);\n\n $profile = new Profile();\n $profile->setAttribute('crm_provider_id', 'owner-complex');\n $service->profile = $profile;\n\n $service->expects($this->exactly(2))\n ->method('createRecord')\n ->willReturnCallback(function ($type, $data) {\n if ($type === 'ContentNote') {\n $decoded = base64_decode($data['Content']);\n\n $this->assertStringContainsString(\"Welch's\", $decoded);\n $this->assertStringContainsString(\"Facebook's Club\", $decoded);\n $this->assertStringContainsString(\"Arctics'\", $decoded);\n $this->assertStringNotContainsString(''', $decoded);\n $this->assertStringNotContainsString('&#039;', $decoded);\n $this->assertStringContainsString('<br>', $decoded);\n $this->assertStringContainsString('<', $decoded);\n $this->assertStringContainsString('>', $decoded);\n\n return 'note-complex';\n }\n\n return 'link-complex';\n });\n\n $complexBody = \"Summary:\\n---------\\nThe call focused on understanding Welch's current challenges and exploring how Arctics' Revenue Growth Management solutions could support their strategic goals.\\n\\n• John SMith discussed his role as a category advisor for Google and Facebook's Club, emphasizing the importance of market research and advising on product assortment.\\n• Madona introduced Arctics' Virtual Shoppers AI, which simulates consumer <behavior> to optimize pricing and promotional strategies.\";\n\n $result = $this->invokePrivateMethod('buildEnhancedNote', $service, [\n 'Jiminny Transcription Summary',\n $complexBody,\n 'task-id-001',\n ]);\n\n $this->assertEquals('note-complex', $result);\n }\n\n public function testSyncRemotelyDeletedObjectsWithErrorHandlingSuccess(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['syncRemotelyDeletedObjects'])\n ->getMock();\n\n // Mock team\n $team = $this->createMock(Team::class);\n $team->method('getUuid')->willReturn('team-uuid-123');\n $serviceMock->team = $team;\n\n // Expect syncRemotelyDeletedObjects to be called once and succeed\n $serviceMock->expects($this->once())\n ->method('syncRemotelyDeletedObjects')\n ->with(\\Jiminny\\Enums\\CrmObject::ACCOUNT);\n\n // Call the protected method using reflection\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');\n $method->setAccessible(true);\n\n // Should not throw any exceptions\n $method->invoke($serviceMock, \\Jiminny\\Enums\\CrmObject::ACCOUNT);\n\n $this->assertTrue(true); // Test completed successfully\n }\n\n public function testSyncRemotelyDeletedObjectsWithErrorHandlingFailure(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['syncRemotelyDeletedObjects'])\n ->getMock();\n\n // Mock team\n $team = $this->createMock(Team::class);\n $team->method('getUuid')->willReturn('team-uuid-456');\n $serviceMock->team = $team;\n\n // Mock logger to verify warning is logged\n $logger = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n\n // Use reflection to set the protected logger property\n $reflection = new \\ReflectionClass($serviceMock);\n $loggerProperty = $reflection->getProperty('logger');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($serviceMock, $logger);\n\n $exception = new \\Exception('Sync failed due to API error');\n\n // Expect syncRemotelyDeletedObjects to throw an exception\n $serviceMock->expects($this->once())\n ->method('syncRemotelyDeletedObjects')\n ->with(\\Jiminny\\Enums\\CrmObject::CONTACT)\n ->willThrowException($exception);\n\n // Expect warning to be logged with correct message and parameters\n $logger->expects($this->once())\n ->method('warning')\n ->with(\n '[Salesforce] Remotely deleted objects sync failed',\n [\n 'objectType' => 'contact',\n 'teamId' => 'team-uuid-456',\n 'reason' => 'Sync failed due to API error',\n ]\n );\n\n // Call the protected method using reflection\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');\n $method->setAccessible(true);\n\n // Should not re-throw the exception, just log it\n $method->invoke($serviceMock, \\Jiminny\\Enums\\CrmObject::CONTACT);\n\n $this->assertTrue(true); // Test completed successfully\n }\n\n public function testSyncRemotelyDeletedObjectsWithErrorHandlingWithLogParams(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['syncRemotelyDeletedObjects'])\n ->getMock();\n\n // Mock team\n $team = $this->createMock(Team::class);\n $team->method('getUuid')->willReturn('team-uuid-789');\n $serviceMock->team = $team;\n\n // Mock logger to verify warning is logged\n $logger = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n\n // Use reflection to set the protected logger property\n $loggerReflection = new \\ReflectionClass($serviceMock);\n $loggerProperty = $loggerReflection->getProperty('logger');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($serviceMock, $logger);\n\n $exception = new \\Exception('Network timeout');\n\n // Expect syncRemotelyDeletedObjects to throw an exception\n $serviceMock->expects($this->once())\n ->method('syncRemotelyDeletedObjects')\n ->with(\\Jiminny\\Enums\\CrmObject::OPPORTUNITY)\n ->willThrowException($exception);\n\n // Additional log parameters\n $logParams = [\n 'syncType' => 'full',\n 'batchSize' => 100,\n ];\n\n // Expect warning to be logged with merged parameters\n $logger->expects($this->once())\n ->method('warning')\n ->with(\n '[Salesforce] Remotely deleted objects sync failed',\n [\n 'objectType' => 'opportunity',\n 'teamId' => 'team-uuid-789',\n 'reason' => 'Network timeout',\n 'syncType' => 'full',\n 'batchSize' => 100,\n ]\n );\n\n // Call the protected method using reflection\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');\n $method->setAccessible(true);\n\n // Should not re-throw the exception, just log it\n $method->invoke($serviceMock, \\Jiminny\\Enums\\CrmObject::OPPORTUNITY, $logParams);\n\n $this->assertTrue(true); // Test completed successfully\n }\n\n /**\n * @dataProvider crmObjectProvider\n */\n public function testSyncRemotelyDeletedObjectsWithErrorHandlingDifferentCrmObjects(\\Jiminny\\Enums\\CrmObject $crmObject): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['syncRemotelyDeletedObjects'])\n ->getMock();\n\n // Mock team\n $team = $this->createMock(Team::class);\n $team->method('getUuid')->willReturn('team-uuid-test');\n $serviceMock->team = $team;\n\n // Mock logger to verify warning is logged\n $logger = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n\n // Use reflection to set the protected logger property\n $loggerReflectionClass = new \\ReflectionClass($serviceMock);\n $loggerProperty = $loggerReflectionClass->getProperty('logger');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($serviceMock, $logger);\n\n $exception = new \\Exception('Test error');\n\n // Expect syncRemotelyDeletedObjects to throw an exception\n $serviceMock->expects($this->once())\n ->method('syncRemotelyDeletedObjects')\n ->with($crmObject)\n ->willThrowException($exception);\n\n // Expect warning to be logged with correct entity type\n $logger->expects($this->once())\n ->method('warning')\n ->with(\n '[Salesforce] Remotely deleted objects sync failed',\n [\n 'objectType' => $crmObject->value,\n 'teamId' => 'team-uuid-test',\n 'reason' => 'Test error',\n ]\n );\n\n // Call the protected method using reflection\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');\n $method->setAccessible(true);\n\n $method->invoke($serviceMock, $crmObject);\n\n $this->assertTrue(true); // Test completed successfully\n }\n\n public function testHandleObjectDeletionWithDeletedEntity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['deleteCrmObject'])\n ->getMock();\n\n $entity = $this->createMock(\\Jiminny\\Models\\Account::class);\n $crmData = ['IsDeleted' => true];\n\n $serviceMock->expects($this->once())\n ->method('deleteCrmObject')\n ->with($entity);\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('handleObjectDeletion');\n $method->setAccessible(true);\n\n $method->invoke($serviceMock, $entity, $crmData);\n }\n\n public function testHandleObjectDeletionWithNonDeletedEntity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['deleteCrmObject'])\n ->getMock();\n\n $entity = $this->createMock(\\Jiminny\\Models\\Contact::class);\n $crmData = ['IsDeleted' => false];\n\n $serviceMock->expects($this->never())\n ->method('deleteCrmObject');\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('handleObjectDeletion');\n $method->setAccessible(true);\n\n $method->invoke($serviceMock, $entity, $crmData);\n }\n\n public function testDeleteCrmObjectWithValidEntity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['dispatchDeleteCrmObjectJob'])\n ->getMock();\n\n $entity = $this->createMock(\\Jiminny\\Models\\Lead::class);\n $entity->expects($this->once())->method('delete');\n\n $serviceMock->expects($this->once())\n ->method('dispatchDeleteCrmObjectJob')\n ->with($entity);\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('deleteCrmObject');\n $method->setAccessible(true);\n\n $method->invoke($serviceMock, $entity);\n }\n\n public function testDeleteCrmObjectWithNullEntity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['dispatchDeleteCrmObjectJob'])\n ->getMock();\n\n $serviceMock->expects($this->never())\n ->method('dispatchDeleteCrmObjectJob');\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('deleteCrmObject');\n $method->setAccessible(true);\n\n $method->invoke($serviceMock, null);\n }\n\n public function testDispatchDeleteCrmObjectJobWithNullEntity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($service);\n $method = $reflection->getMethod('dispatchDeleteCrmObjectJob');\n $method->setAccessible(true);\n\n // Should return early without dispatching - no exception expected\n $method->invoke($service, null);\n\n $this->assertTrue(true); // Test completed successfully\n }\n\n public function testDispatchDeleteCrmObjectJobWithUnsupportedEntity(): void\n {\n $this->expectException(\\TypeError::class);\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n $unsupportedEntity = $this->createMock(\\stdClass::class);\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($service);\n $method = $reflection->getMethod('dispatchDeleteCrmObjectJob');\n\n // This will throw TypeError due to union type constraint\n $method->invoke($service, $unsupportedEntity);\n }\n\n public function testHandleEntityDeletionByProviderIdMethodExists(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n // Test that the method exists and is accessible via reflection\n $reflection = new \\ReflectionClass($service);\n $method = $reflection->getMethod('handleEntityDeletionByProviderId');\n $method->setAccessible(true);\n\n // Verify method exists and has correct parameters\n $this->assertTrue($method->isProtected());\n $this->assertEquals(2, $method->getNumberOfParameters());\n\n $parameters = $method->getParameters();\n $this->assertEquals('targetEntity', $parameters[0]->getName());\n $this->assertEquals('crmData', $parameters[1]->getName());\n }\n\n public function testSyncRemotelyDeletedObjectsWithNoResults(): void\n {\n // Create a real service instance to avoid mock property issues\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n // Mock queryHandler to throw NoResultsException\n $queryHandler = $this->createMock(QueryHandler::class);\n $queryHandler->expects($this->once())\n ->method('queryDeleted')\n ->with('Opportunity')\n ->willThrowException(new NoResultsException('No results'));\n\n // Set the queryHandler using reflection on the real service\n $reflection = new \\ReflectionClass($service);\n $queryHandlerProperty = $reflection->getProperty('queryHandler');\n $queryHandlerProperty->setAccessible(true);\n $queryHandlerProperty->setValue($service, $queryHandler);\n\n $result = self::invokePrivateMethod('syncRemotelyDeletedObjects', $service, [CrmObject::OPPORTUNITY]);\n\n $this->assertFalse($result);\n }\n\n public function testSyncRemotelyDeletedObjectsWithEmptyResults(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n // Mock queryHandler to return empty results\n $queryResult = $this->createMock(QueryResults::class);\n $queryResult->method('getResults')->willReturn([]);\n\n $queryHandler = $this->createMock(QueryHandler::class);\n $queryHandler->expects($this->once())\n ->method('queryDeleted')\n ->with('Opportunity')\n ->willReturn($queryResult);\n\n // Set the queryHandler using reflection on the real service\n $reflection = new \\ReflectionClass($service);\n $queryHandlerProperty = $reflection->getProperty('queryHandler');\n $queryHandlerProperty->setAccessible(true);\n $queryHandlerProperty->setValue($service, $queryHandler);\n\n $result = self::invokePrivateMethod('syncRemotelyDeletedObjects', $service, [CrmObject::OPPORTUNITY]);\n\n $this->assertFalse($result);\n }\n\n public function testSyncRemotelyDeletedObjectsWithUnsupportedCrmObject(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n // Mock queryHandler to return some deleted objects so we reach the match statement\n $deletedObjects = [\n ['id' => 'task1'],\n ['id' => 'task2'],\n ];\n $queryResult = $this->createMock(QueryResults::class);\n $queryResult->method('getResults')->willReturn($deletedObjects);\n\n $queryHandler = $this->createMock(QueryHandler::class);\n $queryHandler->expects($this->once())\n ->method('queryDeleted')\n ->with('Task') // ucfirst('task') = 'Task'\n ->willReturn($queryResult);\n\n self::setPrivateProperty($service, 'queryHandler', $queryHandler);\n\n $this->expectException(InvalidArgumentException::class);\n $this->expectExceptionMessage('Unsupported CrmObject: task');\n\n self::invokePrivateMethod('syncRemotelyDeletedObjects', $service, [CrmObject::TASK]);\n }\n\n public static function crmObjectProvider(): array\n {\n return [\n 'Account' => [CrmObject::ACCOUNT],\n 'Contact' => [CrmObject::CONTACT],\n 'Lead' => [CrmObject::LEAD],\n 'Opportunity' => [CrmObject::OPPORTUNITY],\n ];\n }\n\n public function testVerifyTaskExistsReturnsTrue(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-123', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-123');\n $activity->method('getId')->willReturn(456);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-123', ['Id', 'IsDeleted'])\n ->willReturn(['Id' => 'task-123', 'IsDeleted' => false]);\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertTrue($result);\n }\n\n public function testVerifyTaskExistsReturnsTrueForEvent(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:event-123', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('event-123');\n $activity->method('getId')->willReturn(456);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_EVENT);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Event', 'event-123', ['Id', 'IsDeleted'])\n ->willReturn(['Id' => 'event-123', 'IsDeleted' => false]);\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertTrue($result);\n }\n\n public function testVerifyTaskExistsReturnsFalseWhenDeleted(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-456', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-456');\n $activity->method('getId')->willReturn(789);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-456', ['Id', 'IsDeleted'])\n ->willReturn(['Id' => 'task-456', 'IsDeleted' => true]);\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertFalse($result);\n }\n\n public function testVerifyTaskExistsReturnsFalseWhenNotFound(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-999', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-999');\n $activity->method('getId')->willReturn(999);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-999', ['Id', 'IsDeleted'])\n ->willThrowException(new \\Jiminny\\Exceptions\\HttpNotFoundException('Task not found'));\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertFalse($result);\n }\n\n public function testVerifyTaskExistsReturnsFalseWhenNoPlaybook(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-no-playbook', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-no-playbook');\n $activity->method('getId')->willReturn(111);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn(null);\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertFalse($result);\n }\n\n public function testVerifyTaskExistsThrowsExceptionForTransientErrors(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-error', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-error');\n $activity->method('getId')->willReturn(888);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-error', ['Id', 'IsDeleted'])\n ->willThrowException(new \\RuntimeException('Network timeout'));\n\n $this->expectException(\\RuntimeException::class);\n $this->expectExceptionMessage('Network timeout');\n\n $service->verifyTaskExists($activity);\n }\n\n public function testVerifyTaskExistsCachesResults(): void\n {\n $cachedValue = null;\n Cache::shouldReceive('remember')\n ->twice()\n ->with('crm_task_exists:123:task-cached', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(function ($key, $ttl, $callback) use (&$cachedValue) {\n if ($cachedValue === null) {\n $cachedValue = $callback();\n }\n\n return $cachedValue;\n });\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-cached');\n $activity->method('getId')->willReturn(555);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-cached', ['Id', 'IsDeleted'])\n ->willReturn(['Id' => 'task-cached', 'IsDeleted' => false]);\n\n $result1 = $service->verifyTaskExists($activity);\n $result2 = $service->verifyTaskExists($activity);\n\n $this->assertTrue($result1);\n $this->assertTrue($result2);\n }\n\n public function testVerifyTaskExistsReturnsFalseForHttpBadRequestException(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-400', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-400');\n $activity->method('getId')->willReturn(400);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-400', ['Id', 'IsDeleted'])\n ->willThrowException(new \\Jiminny\\Exceptions\\HttpBadRequestException('Bad request'));\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertFalse($result);\n }\n\n public function testImportOpportunitySkipsWhenNoProfileAndNoAccount(): void\n {\n $crmData = [\n 'Id' => 'SF-NO-USER-1',\n 'Name' => 'Test Opportunity',\n 'OwnerId' => 'owner-no-profile',\n // No AccountId\n ];\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(\\Illuminate\\Events\\Dispatcher::class); // ← ADD THIS\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n\n $service = new Service(\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService\n );\n\n $config = $this->createMock(Configuration::class);\n\n // Mock profiles relation returning null (no profile found)\n $profilesRelation = \\Mockery::mock(\\Illuminate\\Database\\Eloquent\\Relations\\HasMany::class);\n $profilesRelation->shouldReceive('where')->with('crm_provider_id', 'owner-no-profile')->andReturnSelf();\n $profilesRelation->shouldReceive('first')->andReturn(null);\n\n $config->expects($this->once())\n ->method('profiles')\n ->willReturn($profilesRelation);\n\n $team = $this->createMock(Team::class);\n $team->method('getId')->willReturn(1);\n\n $logger = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n $logger->expects($this->once())\n ->method('error')\n ->with(\n '[Salesforce] | Skip import, no user_id found',\n ['id' => 'SF-NO-USER-1']\n );\n\n $reflection = new \\ReflectionClass($service);\n\n $configProperty = $reflection->getProperty('config');\n $configProperty->setAccessible(true);\n $configProperty->setValue($service, $config);\n\n $teamProperty = $reflection->getProperty('team');\n $teamProperty->setAccessible(true);\n $teamProperty->setValue($service, $team);\n\n $loggerProperty = $reflection->getProperty('logger');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($service, $logger);\n\n // Initialize profile property to avoid \"must not be accessed before initialization\" error\n $profileProperty = $reflection->getProperty('profile');\n $profileProperty->setAccessible(true);\n $profileProperty->setValue($service, null);\n\n $result = self::invokePrivateMethod('importOpportunity', $service, [$crmData]);\n\n $this->assertNull($result);\n }\n\n public function testImportOpportunitySkipsWhenProfileNotFound(): void\n {\n $crmData = [\n 'Id' => 'SF-NO-USER-2',\n 'Name' => 'Test Opportunity',\n 'OwnerId' => 'owner-not-found',\n // No AccountId - avoid complex account processing\n ];\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $eventDispatcher = $this->createMock(\\Illuminate\\Events\\Dispatcher::class); // ← ADD THIS\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n\n\n $service = new Service(\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService\n );\n\n $config = $this->createMock(Configuration::class);\n\n // Mock profiles relation returning null (no profile found)\n $profilesRelation = \\Mockery::mock(\\Illuminate\\Database\\Eloquent\\Relations\\HasMany::class);\n $profilesRelation->shouldReceive('where')->with('crm_provider_id', 'owner-not-found')->andReturnSelf();\n $profilesRelation->shouldReceive('first')->andReturn(null);\n\n $config->expects($this->once())\n ->method('profiles')\n ->willReturn($profilesRelation);\n\n $team = $this->createMock(Team::class);\n $team->method('getId')->willReturn(1);\n\n $logger = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n $logger->expects($this->once())\n ->method('error')\n ->with(\n '[Salesforce] | Skip import, no user_id found',\n ['id' => 'SF-NO-USER-2']\n );\n\n $reflection = new \\ReflectionClass($service);\n\n $configProperty = $reflection->getProperty('config');\n $configProperty->setAccessible(true);\n $configProperty->setValue($service, $config);\n\n $teamProperty = $reflection->getProperty('team');\n $teamProperty->setAccessible(true);\n $teamProperty->setValue($service, $team);\n\n $loggerProperty = $reflection->getProperty('logger');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($service, $logger);\n\n // Initialize profile property\n $profileProperty = $reflection->getProperty('profile');\n $profileProperty->setAccessible(true);\n $profileProperty->setValue($service, null);\n\n $result = self::invokePrivateMethod('importOpportunity', $service, [$crmData]);\n\n $this->assertNull($result);\n }\n}\n\nclass HasManyExtended extends HasMany\n{\n public function where()\n {\n }\n\n public function first()\n {\n }\n}","depth":4,"bounds":{"left":0.16223404,"top":0.09736632,"width":0.3550532,"height":0.90263367},"on_screen":true,"value":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Services\\Crm\\Salesforce;\n\nuse Illuminate\\Database\\Eloquent\\Relations\\HasMany;\nuse Illuminate\\Events\\Dispatcher;\nuse Illuminate\\Support\\Facades\\Cache;\nuse Jiminny\\Component\\Country\\CountriesMap;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Enums\\CrmObject;\nuse Jiminny\\Exceptions\\InvalidArgumentException;\nuse Jiminny\\Exceptions\\NoResultsException;\nuse Jiminny\\Integrations\\PlaybookResolver;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Crm\\Configuration;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\TeamSettings;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\ContactRoleRepository;\nuse Jiminny\\Repositories\\Crm\\FieldDataRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\Salesforce\\Client;\nuse Jiminny\\Services\\Crm\\Salesforce\\PayloadBuilder;\nuse Jiminny\\Services\\Crm\\Salesforce\\QueryBuilder;\nuse Jiminny\\Services\\Crm\\Salesforce\\QueryHandler;\nuse Jiminny\\Services\\Crm\\Salesforce\\QueryIterator;\nuse Jiminny\\Services\\Crm\\Salesforce\\QueryResults;\nuse Jiminny\\Services\\Crm\\Salesforce\\Service;\nuse PHPUnit\\Framework\\MockObject\\MockObject;\nuse Tests\\TestCase;\nuse Tests\\Unit\\Traits\\TestPrivateMethod;\n\nclass ServiceTest extends TestCase\n{\n use TestPrivateMethod;\n\n public function testFetchAndAssociateRelatedActivity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $payloadBuilder->method('addCustomLogicFieldsPayload')\n ->willReturnCallback(function ($activity, $payload) {\n return $payload;\n });\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods(['fetchRelatedActivity', 'getPlaybook', 'getPlaybookCategory', 'updateRecord'])\n ->getMock();\n\n $serviceMock->expects($this->once())\n ->method('fetchRelatedActivity')\n ->willReturn([\n 'Id' => 'testId',\n 'Type' => null,\n 'OwnerId' => 'testerUser',\n 'Description' => 'Test description',\n ]);\n\n $user = $this->createMock(User::class);\n $team = $this->createMock(Team::class);\n $user->method('getAttribute')->with('team')->willReturn($team);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityField')->willReturn(null);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_EVENT);\n\n $serviceMock->expects($this->once())\n ->method('getPlaybook')\n ->with($user)\n ->willReturn($playbook);\n\n $serviceMock->expects($this->never())\n ->method('getPlaybookCategory');\n\n $serviceMock->expects($this->never())\n ->method('updateRecord');\n\n $fieldDataRepository = $this->createMock(FieldDataRepository::class);\n $fieldDataRepository->method('getActivityFieldData')->willReturn(collect([]));\n app()->instance(FieldDataRepository::class, $fieldDataRepository);\n\n $config = $this->createMock(Configuration::class);\n $profilesRelation = $this->getMockBuilder(\\Illuminate\\Database\\Eloquent\\Relations\\HasMany::class)\n ->disableOriginalConstructor()\n ->onlyMethods(['get'])\n ->addMethods(['where'])\n ->getMock();\n $profilesRelation->method('where')->willReturnSelf();\n $profilesRelation->method('get')->willReturn(collect([]));\n $config->method('profiles')->willReturn($profilesRelation);\n\n $serviceMock->config = $config;\n $serviceMock->profile = null;\n\n $actualStartTime = \\Carbon\\Carbon::now();\n\n $activity = $this->getMockBuilder(Activity::class)\n ->disableOriginalConstructor()\n ->onlyMethods(['update', 'hasProspect'])\n ->getMock();\n\n $activity->method('update')->willReturn(true);\n $activity->method('hasProspect')->willReturn(true);\n\n $activity->type = Activity::TYPE_CONFERENCE;\n $activity->provider = Activity::PROVIDER_TWILIO;\n $activity->lead_id = 1;\n $activity->user_id = 0;\n $activity->id_string = 'test-activity-id';\n $activity->user = $user;\n\n $activity->actual_start_time = $actualStartTime;\n $activity->uuid = 'c53d8320-f556-4cee-a2f8-5f232f454ca4';\n\n app()->bind(PlaybookResolver::class, function () use ($user) {\n $playbook = $this->createMock(Playbook::class);\n $playbookResolver = $this->createMock(PlaybookResolver::class);\n $playbookResolver->expects($this->once())\n ->method('resolvePlaybookByUser')\n ->with($user)\n ->willReturn($playbook);\n\n return $playbookResolver;\n });\n\n $data = $serviceMock->fetchAndAssociateRelatedActivity($activity);\n\n $this->assertInstanceOf(Activity::class, $data);\n $this->assertEquals(Activity::TYPE_CONFERENCE, $data->getType());\n $this->assertEquals($actualStartTime->getTimestamp(), $data->getActualStartTime()->getTimestamp());\n }\n\n public function testFetchAndAssociateRelatedActivitySkipsForTaskBasedPlaybook(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods(['fetchRelatedActivity', 'getPlaybook'])\n ->getMock();\n\n $user = $this->createMock(User::class);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n $playbook->method('getId')->willReturn(123);\n\n $serviceMock->expects($this->once())\n ->method('getPlaybook')\n ->with($user)\n ->willReturn($playbook);\n\n $serviceMock->expects($this->never())\n ->method('fetchRelatedActivity');\n\n $activity = $this->getMockBuilder(Activity::class)\n ->disableOriginalConstructor()\n ->onlyMethods(['hasProspect', 'getUuid'])\n ->getMock();\n\n $activity->method('hasProspect')->willReturn(true);\n $activity->method('getUuid')->willReturn('c53d8320-f556-4cee-a2f8-5f232f454ca4');\n $activity->type = Activity::TYPE_CONFERENCE;\n $activity->actual_start_time = \\Carbon\\Carbon::now();\n $activity->user = $user;\n\n $result = $serviceMock->fetchAndAssociateRelatedActivity($activity);\n\n $this->assertNull($result);\n }\n\n public function testFetchAndAssociateRelatedActivityReturnsNullForNonConference(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $serviceMock = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)\n );\n\n $activity = $this->getMockBuilder(Activity::class)\n ->disableOriginalConstructor()\n ->getMock();\n\n $activity->type = Activity::TYPE_SOFTPHONE;\n\n $result = $serviceMock->fetchAndAssociateRelatedActivity($activity);\n\n $this->assertNull($result);\n }\n\n public function testFetchAndAssociateRelatedActivityReturnsNullWhenNoStartTime(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $serviceMock = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)\n );\n\n\n $activity = $this->getMockBuilder(Activity::class)\n ->disableOriginalConstructor()\n ->getMock();\n\n $activity->type = Activity::TYPE_CONFERENCE;\n $activity->actual_start_time = null;\n $activity->scheduled_start_time = null;\n\n $result = $serviceMock->fetchAndAssociateRelatedActivity($activity);\n\n $this->assertNull($result);\n }\n\n public function testFetchAndAssociateRelatedActivityReturnsNullWhenNoProspect(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods(['getPlaybook'])\n ->getMock();\n\n $serviceMock->expects($this->never())\n ->method('getPlaybook');\n\n $activity = $this->getMockBuilder(Activity::class)\n ->disableOriginalConstructor()\n ->onlyMethods(['hasProspect', 'getUuid'])\n ->getMock();\n\n $activity->method('hasProspect')->willReturn(false);\n $activity->method('getUuid')->willReturn('c53d8320-f556-4cee-a2f8-5f232f454ca4');\n $activity->type = Activity::TYPE_CONFERENCE;\n $activity->actual_start_time = \\Carbon\\Carbon::now();\n\n $result = $serviceMock->fetchAndAssociateRelatedActivity($activity);\n\n $this->assertNull($result);\n }\n\n public function testMatchExactlyByEmail(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods([])\n ->getMock();\n\n $profile = new Profile();\n $profile->setAttribute('id', bin2hex(random_bytes(8)));\n $serviceMock->profile = $profile;\n\n $team = $this->createMock(Team::class);\n $serviceMock->team = $team;\n\n $data = $serviceMock->matchExactlyByEmail(bin2hex(random_bytes(8)) . 'test_email@testserver.com');\n\n $this->assertEquals(null, $data);\n }\n\n public function testMatchDomainFromEmail(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $queryIterator = $this->createMock(QueryIterator::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $config = $this->createMock(Configuration::class);\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('search')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods(['convertCrmData'])\n ->getMock();\n\n $profile = new Profile();\n $profile->account_fields = 'Field1, Field2, Field3';\n $serviceMock->profile = $profile;\n\n $serviceMock->expects($this->once())\n ->method('convertCrmData')\n ->willReturn(['test']);\n\n $this->app->bind(QueryBuilder::class, function () {\n $queryBuilder = $this->createMock(QueryBuilder::class);\n $queryBuilder->expects($this->once())\n ->method('buildMatchByDomainQuery')\n ->with('test_email@testserver.com')\n ->willReturn('FIND {test_email@testserver.com} IN ALL FIELDS RETURNING Account(Id)');\n\n return $queryBuilder;\n });\n\n $team = $this->createMock(Team::class);\n\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n\n $data = $serviceMock->matchByDomain('test_email@testserver.com');\n\n $this->assertEquals(['test'], $data);\n }\n\n public function testBuildTaskSearchFields(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)\n );\n\n $fields = $service->buildTaskSearchFields();\n\n $expectedFields = ['Id', 'WhoId', 'WhatId', 'AccountId'];\n\n $this->assertEquals($expectedFields, $fields);\n }\n\n public function testMapCrmObjects(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class),\n );\n\n $sampleTask = [\n 'WhoId' => '003sampleWhoId',\n 'AccountId' => 'sampleAccountId',\n 'WhatId' => 'sampleWhatId',\n ];\n\n $activityData = $service->mapCrmObjects($sampleTask);\n\n $expectedActivityData = [\n 'contact' => '003sampleWhoId',\n 'account' => 'sampleAccountId',\n 'opportunity' => 'sampleWhatId',\n ];\n\n $this->assertEquals($expectedActivityData, $activityData);\n }\n\n public function testGetInstalledAppVersion(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n $queryIterator = $this->createMock(QueryIterator::class);\n $queryIterator->expects($this->any())\n ->method('current')->willReturn([\n 'SubscriberPackageVersion' => [\n 'MajorVersion' => '1',\n 'MinorVersion' => '0',\n 'PatchVersion' => '1',\n 'BuildNumber' => '0',\n ],\n ]);\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('metadata')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods(array_diff(get_class_methods(Service::class), ['getInstalledAppVersion']))\n ->getMock();\n\n $version = $serviceMock->getInstalledAppVersion();\n\n $this->assertEquals('1010', $version);\n }\n\n public function testSyncProfiles(): void\n {\n $userToSearch = null;\n\n $team = $this->createMock(Team::class);\n $config = $this->createMockedConfiguration();\n $config->expects($this->once())\n ->method('getId')\n ->willReturn(1);\n\n $salesforceUser = [\n 'Email' => 'test@example.com',\n 'UserPreferencesLightningExperiencePreferred' => true,\n 'CallCenterId' => '123',\n 'Id' => '456',\n 'ProfileId' => '789',\n ];\n\n app()->bind(QueryBuilder::class, function () use ($userToSearch) {\n $queryBuilder = $this->createMock(QueryBuilder::class);\n $queryBuilder->expects($this->once())\n ->method('buildGetUsersQuery')\n ->with($userToSearch)\n ->willReturn('SELECT * FROM Users');\n\n return $queryBuilder;\n });\n\n $queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('query')\n ->with('SELECT * FROM Users')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n\n $user = $this->createMock(User::class);\n $user->expects($this->once())\n ->method('getId')\n ->willReturn(123);\n\n $this->mockTeamRepository($team, $salesforceUser, $user);\n\n $profileRepository = $this->createMock(ProfileRepository::class);\n $profileRepository->expects($this->once())\n ->method('updateOrCreateProfile')\n ->with(\n $user,\n [\n 'crm_configuration_id' => 1,\n 'crm_provider_id' => '456',\n ],\n [\n 'user_id' => 123,\n 'edition' => Profile::EDITION_LIGHTNING,\n 'has_external_cti' => true,\n 'crm_profile_id' => '789',\n ]\n )\n ->willReturn(new Profile());\n\n $this->app->instance(ProfileRepository::class, $profileRepository);\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n $result = $serviceMock->syncProfiles($userToSearch);\n\n $this->assertNull($result);\n }\n\n public function testSyncProfilesEmailIsNull(): void\n {\n $userToSearch = $this->createMock(User::class);\n\n $salesforceUser = [\n 'Email' => null,\n ];\n\n app()->bind(QueryBuilder::class, function () {\n $queryBuilder = $this->createMock(QueryBuilder::class);\n $queryBuilder->expects($this->once())\n ->method('buildGetUsersQuery')\n ->with(null)\n ->willReturn('SELECT * FROM Users');\n\n return $queryBuilder;\n });\n\n $queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->exactly(2))\n ->method('query')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n\n $team = $this->createMock(Team::class);\n $user = $this->createMock(User::class);\n $this->mockTeamRepository($team, $salesforceUser, $user, false);\n\n $config = $this->createMock(Configuration::class);\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n\n $profile = $serviceMock->syncProfiles(null);\n\n $this->assertNull($profile);\n }\n\n public function testSyncProfilesUserToSearchMatchesCurrentUser(): void\n {\n $userToSearch = $this->createMock(User::class);\n $userToSearch->expects($this->once())\n ->method('getId')\n ->willReturn(123);\n\n $team = $this->createMock(Team::class);\n $config = $this->createMock(Configuration::class);\n $config->expects($this->once())\n ->method('getId')\n ->willReturn(1);\n\n $salesforceUser = [\n 'Email' => 'test@example.com',\n 'UserPreferencesLightningExperiencePreferred' => true,\n 'CallCenterId' => '123',\n 'Id' => '456',\n 'ProfileId' => '789',\n ];\n\n app()->bind(QueryBuilder::class, function () use ($userToSearch) {\n $queryBuilder = $this->createMock(QueryBuilder::class);\n $queryBuilder->expects($this->once())\n ->method('buildGetUsersQuery')\n ->with($userToSearch)\n ->willReturn('SELECT * FROM Users');\n\n return $queryBuilder;\n });\n\n $queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('query')\n ->with('SELECT * FROM Users')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n\n $user = $this->createMock(User::class);\n $user->expects($this->exactly(2))\n ->method('getId')\n ->willReturn(123);\n\n $this->mockTeamRepository($team, $salesforceUser, $user);\n\n $profileRepository = $this->createMock(ProfileRepository::class);\n $profileRepository->expects($this->once())\n ->method('updateOrCreateProfile')\n ->with(\n $user,\n [\n 'crm_configuration_id' => 1,\n 'crm_provider_id' => '456',\n ],\n [\n 'user_id' => 123,\n 'edition' => Profile::EDITION_LIGHTNING,\n 'has_external_cti' => true,\n 'crm_profile_id' => '789',\n ]\n )\n ->willReturn(new Profile());\n\n $this->app->instance(ProfileRepository::class, $profileRepository);\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n $profile = $serviceMock->syncProfiles($userToSearch);\n\n $this->assertInstanceOf(Profile::class, $profile);\n }\n\n public function testSyncProfilesWithCustomValidation(): void\n {\n $userToSearch = null;\n\n $team = $this->createMock(Team::class);\n $config = $this->createMockedConfiguration();\n $config->expects($this->atLeastOnce()) // Changed from once() to atLeastOnce()\n ->method('getId')\n ->willReturn(1);\n\n $salesforceUser = [\n 'Email' => 'test@example.com',\n 'UserPreferencesLightningExperiencePreferred' => true,\n 'CallCenterId' => '123',\n 'Id' => '456',\n 'ProfileId' => '789',\n 'CustomField' => 'CustomValue',\n ];\n\n $customRules = [\n ['field' => 'CustomField', 'value' => 'CustomValue'],\n ];\n\n $this->mockQueryBuilderAndHandler($userToSearch, [$salesforceUser]);\n\n $user = $this->createMock(User::class);\n $user->expects($this->once())\n ->method('getId')\n ->willReturn(123);\n\n $this->mockTeamRepository($team, $salesforceUser, $user, true, $customRules);\n\n $this->mockProfileRepository($user);\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n $profile = $serviceMock->syncProfiles($userToSearch);\n\n $this->assertNull($profile);\n }\n\n public function testSyncProfilesWithCustomValidationFailing(): void\n {\n $userToSearch = null;\n\n $team = $this->createMock(Team::class);\n $config = $this->createMockedConfiguration();\n\n $salesforceUser = [\n 'Email' => 'test@example.com',\n 'UserPreferencesLightningExperiencePreferred' => true,\n 'CallCenterId' => '123',\n 'Id' => '456',\n 'ProfileId' => '789',\n 'CustomField' => 'WrongValue',\n ];\n\n $customRules = [\n ['field' => 'CustomField', 'value' => 'CustomValue'],\n ];\n\n $this->mockQueryBuilderAndHandler($userToSearch, [$salesforceUser]);\n\n $teamRepository = $this->getMockForAbstractClass(TeamRepository::class, [], '', false, true, true, ['findActiveTeamMemberByEmail', 'getTeamSetting']);\n\n $teamSettings = $this->createMock(TeamSettings::class);\n $teamSettings->method('getValueType')\n ->willReturn('array');\n\n $teamSettings->method('getValue')\n ->willReturn(json_encode($customRules));\n\n $teamRepository->expects($this->once())\n ->method('getTeamSetting')\n ->with($team, 'custom_profile_validation')\n ->willReturn($teamSettings);\n\n app()->bind(TeamRepository::class, function () use ($teamRepository) {\n return $teamRepository;\n });\n\n $profileRepository = $this->createMock(ProfileRepository::class);\n $profileRepository->expects($this->never())\n ->method('updateOrCreateProfile');\n\n $this->app->instance(ProfileRepository::class, $profileRepository);\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n $result = $serviceMock->syncProfiles($userToSearch);\n\n $this->assertNull($result);\n }\n\n public function testGetContactRolesFromCrm(): void\n {\n $contactRoles = [\n [\n 'Id' => '1',\n 'ContactId' => 'Contact1',\n 'OpportunityId' => 'Opportunity1',\n 'Opportunity' => ['OwnerId' => 'Owner1'],\n 'IsPrimary' => true,\n 'Role' => 'Decision Maker',\n ],\n ];\n\n $expectedResponse = [\n [\n 'id' => '1',\n 'contactId' => 'Contact1',\n 'opportunityId' => 'Opportunity1',\n 'ownerId' => 'Owner1',\n 'isPrimary' => true,\n 'role' => 'Decision Maker',\n ],\n ];\n\n $this->bindQueryIterator($contactRoles);\n\n $serviceMock = $this->getServiceMock();\n\n $data = $serviceMock->getContactRolesFromCrm(now()->subDay());\n\n $this->assertEquals($expectedResponse, $data);\n }\n\n public function testGetContactRolesFromCrmNoResult(): void\n {\n $this->bindQueryIterator([]);\n\n $serviceMock = $this->getServiceMock();\n\n $data = $serviceMock->getContactRolesFromCrm(now()->subDay());\n\n $this->assertEquals([], $data);\n }\n\n public function testSyncContactRoles(): void\n {\n $contactRoles = [\n [\n 'id' => '1',\n 'contactId' => 'Contact1',\n 'opportunityId' => 'Opportunity1',\n 'ownerId' => 'Owner1',\n 'isPrimary' => true,\n 'role' => 'Decision Maker',\n ],\n ];\n\n app()->bind(ContactRoleRepository::class, function () {\n $contactRoleRepository = $this->createMock(ContactRoleRepository::class);\n $contactRoleRepository->expects($this->once())\n ->method('saveContactRoles');\n\n return $contactRoleRepository;\n });\n\n $serviceMock = $this->getServiceMock([\n 'getContactRolesFromCrm',\n 'syncRemotelyDeletedContactRoles',\n 'syncContact',\n 'syncOpportunity',\n ]);\n\n $config = $this->createMock(Configuration::class);\n $hasMany = $this->createMock(HasManyExtended::class);\n $hasMany->expects($this->exactly(2))\n ->method('where')\n ->willReturn($hasMany);\n\n $hasMany->expects($this->exactly(2))\n ->method('first')\n ->willReturn(\n $this->createMock(Contact::class),\n $this->createMock(Opportunity::class)\n );\n\n $config->expects($this->once())\n ->method('contacts')\n ->willReturn($hasMany);\n $config->expects($this->once())\n ->method('opportunities')\n ->willReturn($hasMany);\n\n $serviceMock->config = $config;\n\n $serviceMock->expects($this->once())\n ->method('getContactRolesFromCrm')\n ->willReturn($contactRoles);\n\n $serviceMock->expects($this->once())\n ->method('syncRemotelyDeletedContactRoles');\n\n $data = $serviceMock->syncContactRoles(now()->subDay());\n\n $this->assertEquals(1, $data);\n }\n\n public function testSyncRemotelyDeletedContactRoles(): void\n {\n $contactRoles = [\n [\n 'id' => '1',\n 'crm_provider_id' => '1',\n ],\n ];\n\n app()->bind(QueryHandler::class, function () use ($contactRoles) {\n $queryResults = new QueryResults($contactRoles, 1, true, null);\n\n $handler = $this->createMock(QueryHandler::class);\n $handler->method('queryDeleted')\n ->willReturn($queryResults);\n\n return $handler;\n });\n\n app()->bind(ContactRoleRepository::class, function () {\n $contactRoleRepository = $this->createMock(ContactRoleRepository::class);\n $contactRoleRepository->expects($this->once())\n ->method('deleteContactRoles');\n\n return $contactRoleRepository;\n });\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $this->createMock(Team::class);\n\n $data = $this->invokePrivateMethod('syncRemotelyDeletedContactRoles', $serviceMock, []);\n\n $this->assertTrue($data);\n }\n\n private function bindQueryIterator(array $queryResult): void\n {\n /** @var Client $client */\n $client = $this->createMock(Client::class);\n $queryIterator = new QueryIterator(\n $client,\n new QueryResults($queryResult, 1, true, null)\n );\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('query')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n }\n\n public static function getOpportunitySortOrderDataProvider(): array\n {\n return [\n 'all open recently updated' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED, ['LastModifiedDate DESC', true]],\n 'all open recently created' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED, ['CreatedDate DESC', true]],\n 'all open oldest created' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED, ['CreatedDate ASC', true]],\n 'all recently updated' => [Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED, ['LastModifiedDate DESC', false]],\n 'default' => ['unknown', ['LastModifiedDate DESC', true]],\n ];\n }\n\n private function createMockedConfiguration(): Configuration\n {\n $config = $this->createMock(Configuration::class);\n $profilesRelation = $this->getMockBuilder(\\Illuminate\\Database\\Eloquent\\Relations\\HasMany::class)\n ->disableOriginalConstructor()\n ->onlyMethods(['get'])\n ->addMethods(['where', 'first'])\n ->getMock();\n $profilesRelation->method('where')->willReturnSelf();\n $profilesRelation->method('get')->willReturn(collect([]));\n $profilesRelation->method('first')->willReturn(null);\n $config->method('profiles')->willReturn($profilesRelation);\n\n return $config;\n }\n\n private function getServiceMock(array $onlyMethods = []): MockObject&Service\n {\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $this->createMock(Client::class),\n $this->createMock(PayloadBuilder::class),\n $this->createMock(Dispatcher::class),\n $this->createMock(CountriesMap::class),\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods($onlyMethods)\n ->getMock();\n\n $serviceMock->profile = $this->createMock(Profile::class);\n\n return $serviceMock;\n }\n\n private function mockQueryBuilderAndHandler($userToSearch, $salesforceUsers): void\n {\n app()->bind(QueryBuilder::class, function () use ($userToSearch) {\n $queryBuilder = $this->createMock(QueryBuilder::class);\n $queryBuilder->expects($this->once())\n ->method('buildGetUsersQuery')\n ->with($userToSearch)\n ->willReturn('SELECT * FROM Users');\n\n return $queryBuilder;\n });\n\n $queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults($salesforceUsers, count($salesforceUsers), true, null));\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('query')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n }\n\n private function mockTeamRepository(\n Team $team,\n array $salesforceUser,\n ?User $user = null,\n bool $userSearch = true,\n array $customRules = []\n ): void {\n $teamRepository = $this->getMockForAbstractClass(TeamRepository::class, [], '', false, true, true, ['findActiveTeamMemberByEmail', 'getTeamSetting']);\n\n if ($userSearch) {\n $teamRepository->expects($this->once())\n ->method('findActiveTeamMemberByEmail')\n ->with($team, $salesforceUser['Email'])\n ->willReturn($user);\n }\n\n $teamSettings = $this->createMock(TeamSettings::class);\n $teamSettings->method('getValueType')\n ->willReturn('array');\n\n $teamSettings->method('getValue')\n ->willReturn(json_encode($customRules));\n\n $teamRepository->expects($this->once())\n ->method('getTeamSetting')\n ->with($team, 'custom_profile_validation')\n ->willReturn($teamSettings);\n\n app()->bind(TeamRepository::class, function () use ($teamRepository) {\n return $teamRepository;\n });\n }\n\n private function mockProfileRepository(User $user): void\n {\n $profileRepository = $this->createMock(ProfileRepository::class);\n $profileRepository->expects($this->once())\n ->method('updateOrCreateProfile')\n ->with(\n $user,\n [\n 'crm_configuration_id' => 1,\n 'crm_provider_id' => '456',\n ],\n [\n 'user_id' => 123,\n 'edition' => Profile::EDITION_LIGHTNING,\n 'has_external_cti' => true,\n 'crm_profile_id' => '789',\n ]\n )\n ->willReturn(new Profile());\n\n $this->app->instance(ProfileRepository::class, $profileRepository);\n }\n\n public function testBuildEnhancedNoteDecodesHtmlEntities(): void\n {\n $service = $this->getServiceMock(['createRecord']);\n\n $profile = new Profile();\n $profile->setAttribute('crm_provider_id', 'owner-123');\n $service->profile = $profile;\n\n $service->expects($this->exactly(2))\n ->method('createRecord')\n ->willReturnOnConsecutiveCalls('note-id-123', 'link-id-456');\n\n $bodyWithEntities = 'Welch's current challenges and Facebook's Club';\n\n $result = $this->invokePrivateMethod('buildEnhancedNote', $service, [\n 'Test Title',\n $bodyWithEntities,\n 'object-id-789',\n ]);\n\n $this->assertEquals('note-id-123', $result);\n }\n\n public function testBuildEnhancedNoteSanitizesWithoutQuotes(): void\n {\n $service = $this->getServiceMock(['createRecord']);\n\n $profile = new Profile();\n $profile->setAttribute('crm_provider_id', 'owner-456');\n $service->profile = $profile;\n\n $service->expects($this->exactly(2))\n ->method('createRecord')\n ->willReturnCallback(function ($type, $data) {\n if ($type === 'ContentNote') {\n $decoded = base64_decode($data['Content']);\n $this->assertStringContainsString(\"Welch's\", $decoded);\n $this->assertStringNotContainsString(''', $decoded);\n $this->assertStringNotContainsString('&#039;', $decoded);\n $this->assertStringContainsString('<script>', $decoded);\n\n return 'note-id-456';\n }\n\n return 'link-id-789';\n });\n\n $bodyWithMixedContent = \"Welch's and <script>alert('xss')</script>\";\n\n $result = $this->invokePrivateMethod('buildEnhancedNote', $service, [\n 'Test Title',\n $bodyWithMixedContent,\n 'object-id-123',\n ]);\n\n $this->assertEquals('note-id-456', $result);\n }\n\n public function testBuildEnhancedNoteConvertsLineBreaks(): void\n {\n $service = $this->getServiceMock(['createRecord']);\n\n $profile = new Profile();\n $profile->setAttribute('crm_provider_id', 'owner-789');\n $service->profile = $profile;\n\n $service->expects($this->exactly(2))\n ->method('createRecord')\n ->willReturnCallback(function ($type, $data) {\n if ($type === 'ContentNote') {\n $decoded = base64_decode($data['Content']);\n $this->assertStringContainsString('<br>', $decoded);\n $this->assertStringNotContainsString('<br />', $decoded);\n\n return 'note-id-789';\n }\n\n return 'link-id-012';\n });\n\n $bodyWithLineBreaks = \"Line 1\\nLine 2\\nLine 3\";\n\n $result = $this->invokePrivateMethod('buildEnhancedNote', $service, [\n 'Test Title',\n $bodyWithLineBreaks,\n 'object-id-456',\n ]);\n\n $this->assertEquals('note-id-789', $result);\n }\n\n public function testBuildEnhancedNoteHandlesComplexScenario(): void\n {\n $service = $this->getServiceMock(['createRecord']);\n\n $profile = new Profile();\n $profile->setAttribute('crm_provider_id', 'owner-complex');\n $service->profile = $profile;\n\n $service->expects($this->exactly(2))\n ->method('createRecord')\n ->willReturnCallback(function ($type, $data) {\n if ($type === 'ContentNote') {\n $decoded = base64_decode($data['Content']);\n\n $this->assertStringContainsString(\"Welch's\", $decoded);\n $this->assertStringContainsString(\"Facebook's Club\", $decoded);\n $this->assertStringContainsString(\"Arctics'\", $decoded);\n $this->assertStringNotContainsString(''', $decoded);\n $this->assertStringNotContainsString('&#039;', $decoded);\n $this->assertStringContainsString('<br>', $decoded);\n $this->assertStringContainsString('<', $decoded);\n $this->assertStringContainsString('>', $decoded);\n\n return 'note-complex';\n }\n\n return 'link-complex';\n });\n\n $complexBody = \"Summary:\\n---------\\nThe call focused on understanding Welch's current challenges and exploring how Arctics' Revenue Growth Management solutions could support their strategic goals.\\n\\n• John SMith discussed his role as a category advisor for Google and Facebook's Club, emphasizing the importance of market research and advising on product assortment.\\n• Madona introduced Arctics' Virtual Shoppers AI, which simulates consumer <behavior> to optimize pricing and promotional strategies.\";\n\n $result = $this->invokePrivateMethod('buildEnhancedNote', $service, [\n 'Jiminny Transcription Summary',\n $complexBody,\n 'task-id-001',\n ]);\n\n $this->assertEquals('note-complex', $result);\n }\n\n public function testSyncRemotelyDeletedObjectsWithErrorHandlingSuccess(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['syncRemotelyDeletedObjects'])\n ->getMock();\n\n // Mock team\n $team = $this->createMock(Team::class);\n $team->method('getUuid')->willReturn('team-uuid-123');\n $serviceMock->team = $team;\n\n // Expect syncRemotelyDeletedObjects to be called once and succeed\n $serviceMock->expects($this->once())\n ->method('syncRemotelyDeletedObjects')\n ->with(\\Jiminny\\Enums\\CrmObject::ACCOUNT);\n\n // Call the protected method using reflection\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');\n $method->setAccessible(true);\n\n // Should not throw any exceptions\n $method->invoke($serviceMock, \\Jiminny\\Enums\\CrmObject::ACCOUNT);\n\n $this->assertTrue(true); // Test completed successfully\n }\n\n public function testSyncRemotelyDeletedObjectsWithErrorHandlingFailure(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['syncRemotelyDeletedObjects'])\n ->getMock();\n\n // Mock team\n $team = $this->createMock(Team::class);\n $team->method('getUuid')->willReturn('team-uuid-456');\n $serviceMock->team = $team;\n\n // Mock logger to verify warning is logged\n $logger = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n\n // Use reflection to set the protected logger property\n $reflection = new \\ReflectionClass($serviceMock);\n $loggerProperty = $reflection->getProperty('logger');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($serviceMock, $logger);\n\n $exception = new \\Exception('Sync failed due to API error');\n\n // Expect syncRemotelyDeletedObjects to throw an exception\n $serviceMock->expects($this->once())\n ->method('syncRemotelyDeletedObjects')\n ->with(\\Jiminny\\Enums\\CrmObject::CONTACT)\n ->willThrowException($exception);\n\n // Expect warning to be logged with correct message and parameters\n $logger->expects($this->once())\n ->method('warning')\n ->with(\n '[Salesforce] Remotely deleted objects sync failed',\n [\n 'objectType' => 'contact',\n 'teamId' => 'team-uuid-456',\n 'reason' => 'Sync failed due to API error',\n ]\n );\n\n // Call the protected method using reflection\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');\n $method->setAccessible(true);\n\n // Should not re-throw the exception, just log it\n $method->invoke($serviceMock, \\Jiminny\\Enums\\CrmObject::CONTACT);\n\n $this->assertTrue(true); // Test completed successfully\n }\n\n public function testSyncRemotelyDeletedObjectsWithErrorHandlingWithLogParams(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['syncRemotelyDeletedObjects'])\n ->getMock();\n\n // Mock team\n $team = $this->createMock(Team::class);\n $team->method('getUuid')->willReturn('team-uuid-789');\n $serviceMock->team = $team;\n\n // Mock logger to verify warning is logged\n $logger = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n\n // Use reflection to set the protected logger property\n $loggerReflection = new \\ReflectionClass($serviceMock);\n $loggerProperty = $loggerReflection->getProperty('logger');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($serviceMock, $logger);\n\n $exception = new \\Exception('Network timeout');\n\n // Expect syncRemotelyDeletedObjects to throw an exception\n $serviceMock->expects($this->once())\n ->method('syncRemotelyDeletedObjects')\n ->with(\\Jiminny\\Enums\\CrmObject::OPPORTUNITY)\n ->willThrowException($exception);\n\n // Additional log parameters\n $logParams = [\n 'syncType' => 'full',\n 'batchSize' => 100,\n ];\n\n // Expect warning to be logged with merged parameters\n $logger->expects($this->once())\n ->method('warning')\n ->with(\n '[Salesforce] Remotely deleted objects sync failed',\n [\n 'objectType' => 'opportunity',\n 'teamId' => 'team-uuid-789',\n 'reason' => 'Network timeout',\n 'syncType' => 'full',\n 'batchSize' => 100,\n ]\n );\n\n // Call the protected method using reflection\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');\n $method->setAccessible(true);\n\n // Should not re-throw the exception, just log it\n $method->invoke($serviceMock, \\Jiminny\\Enums\\CrmObject::OPPORTUNITY, $logParams);\n\n $this->assertTrue(true); // Test completed successfully\n }\n\n /**\n * @dataProvider crmObjectProvider\n */\n public function testSyncRemotelyDeletedObjectsWithErrorHandlingDifferentCrmObjects(\\Jiminny\\Enums\\CrmObject $crmObject): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['syncRemotelyDeletedObjects'])\n ->getMock();\n\n // Mock team\n $team = $this->createMock(Team::class);\n $team->method('getUuid')->willReturn('team-uuid-test');\n $serviceMock->team = $team;\n\n // Mock logger to verify warning is logged\n $logger = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n\n // Use reflection to set the protected logger property\n $loggerReflectionClass = new \\ReflectionClass($serviceMock);\n $loggerProperty = $loggerReflectionClass->getProperty('logger');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($serviceMock, $logger);\n\n $exception = new \\Exception('Test error');\n\n // Expect syncRemotelyDeletedObjects to throw an exception\n $serviceMock->expects($this->once())\n ->method('syncRemotelyDeletedObjects')\n ->with($crmObject)\n ->willThrowException($exception);\n\n // Expect warning to be logged with correct entity type\n $logger->expects($this->once())\n ->method('warning')\n ->with(\n '[Salesforce] Remotely deleted objects sync failed',\n [\n 'objectType' => $crmObject->value,\n 'teamId' => 'team-uuid-test',\n 'reason' => 'Test error',\n ]\n );\n\n // Call the protected method using reflection\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');\n $method->setAccessible(true);\n\n $method->invoke($serviceMock, $crmObject);\n\n $this->assertTrue(true); // Test completed successfully\n }\n\n public function testHandleObjectDeletionWithDeletedEntity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['deleteCrmObject'])\n ->getMock();\n\n $entity = $this->createMock(\\Jiminny\\Models\\Account::class);\n $crmData = ['IsDeleted' => true];\n\n $serviceMock->expects($this->once())\n ->method('deleteCrmObject')\n ->with($entity);\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('handleObjectDeletion');\n $method->setAccessible(true);\n\n $method->invoke($serviceMock, $entity, $crmData);\n }\n\n public function testHandleObjectDeletionWithNonDeletedEntity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['deleteCrmObject'])\n ->getMock();\n\n $entity = $this->createMock(\\Jiminny\\Models\\Contact::class);\n $crmData = ['IsDeleted' => false];\n\n $serviceMock->expects($this->never())\n ->method('deleteCrmObject');\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('handleObjectDeletion');\n $method->setAccessible(true);\n\n $method->invoke($serviceMock, $entity, $crmData);\n }\n\n public function testDeleteCrmObjectWithValidEntity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['dispatchDeleteCrmObjectJob'])\n ->getMock();\n\n $entity = $this->createMock(\\Jiminny\\Models\\Lead::class);\n $entity->expects($this->once())->method('delete');\n\n $serviceMock->expects($this->once())\n ->method('dispatchDeleteCrmObjectJob')\n ->with($entity);\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('deleteCrmObject');\n $method->setAccessible(true);\n\n $method->invoke($serviceMock, $entity);\n }\n\n public function testDeleteCrmObjectWithNullEntity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['dispatchDeleteCrmObjectJob'])\n ->getMock();\n\n $serviceMock->expects($this->never())\n ->method('dispatchDeleteCrmObjectJob');\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('deleteCrmObject');\n $method->setAccessible(true);\n\n $method->invoke($serviceMock, null);\n }\n\n public function testDispatchDeleteCrmObjectJobWithNullEntity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($service);\n $method = $reflection->getMethod('dispatchDeleteCrmObjectJob');\n $method->setAccessible(true);\n\n // Should return early without dispatching - no exception expected\n $method->invoke($service, null);\n\n $this->assertTrue(true); // Test completed successfully\n }\n\n public function testDispatchDeleteCrmObjectJobWithUnsupportedEntity(): void\n {\n $this->expectException(\\TypeError::class);\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n $unsupportedEntity = $this->createMock(\\stdClass::class);\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($service);\n $method = $reflection->getMethod('dispatchDeleteCrmObjectJob');\n\n // This will throw TypeError due to union type constraint\n $method->invoke($service, $unsupportedEntity);\n }\n\n public function testHandleEntityDeletionByProviderIdMethodExists(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n // Test that the method exists and is accessible via reflection\n $reflection = new \\ReflectionClass($service);\n $method = $reflection->getMethod('handleEntityDeletionByProviderId');\n $method->setAccessible(true);\n\n // Verify method exists and has correct parameters\n $this->assertTrue($method->isProtected());\n $this->assertEquals(2, $method->getNumberOfParameters());\n\n $parameters = $method->getParameters();\n $this->assertEquals('targetEntity', $parameters[0]->getName());\n $this->assertEquals('crmData', $parameters[1]->getName());\n }\n\n public function testSyncRemotelyDeletedObjectsWithNoResults(): void\n {\n // Create a real service instance to avoid mock property issues\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n // Mock queryHandler to throw NoResultsException\n $queryHandler = $this->createMock(QueryHandler::class);\n $queryHandler->expects($this->once())\n ->method('queryDeleted')\n ->with('Opportunity')\n ->willThrowException(new NoResultsException('No results'));\n\n // Set the queryHandler using reflection on the real service\n $reflection = new \\ReflectionClass($service);\n $queryHandlerProperty = $reflection->getProperty('queryHandler');\n $queryHandlerProperty->setAccessible(true);\n $queryHandlerProperty->setValue($service, $queryHandler);\n\n $result = self::invokePrivateMethod('syncRemotelyDeletedObjects', $service, [CrmObject::OPPORTUNITY]);\n\n $this->assertFalse($result);\n }\n\n public function testSyncRemotelyDeletedObjectsWithEmptyResults(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n // Mock queryHandler to return empty results\n $queryResult = $this->createMock(QueryResults::class);\n $queryResult->method('getResults')->willReturn([]);\n\n $queryHandler = $this->createMock(QueryHandler::class);\n $queryHandler->expects($this->once())\n ->method('queryDeleted')\n ->with('Opportunity')\n ->willReturn($queryResult);\n\n // Set the queryHandler using reflection on the real service\n $reflection = new \\ReflectionClass($service);\n $queryHandlerProperty = $reflection->getProperty('queryHandler');\n $queryHandlerProperty->setAccessible(true);\n $queryHandlerProperty->setValue($service, $queryHandler);\n\n $result = self::invokePrivateMethod('syncRemotelyDeletedObjects', $service, [CrmObject::OPPORTUNITY]);\n\n $this->assertFalse($result);\n }\n\n public function testSyncRemotelyDeletedObjectsWithUnsupportedCrmObject(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n // Mock queryHandler to return some deleted objects so we reach the match statement\n $deletedObjects = [\n ['id' => 'task1'],\n ['id' => 'task2'],\n ];\n $queryResult = $this->createMock(QueryResults::class);\n $queryResult->method('getResults')->willReturn($deletedObjects);\n\n $queryHandler = $this->createMock(QueryHandler::class);\n $queryHandler->expects($this->once())\n ->method('queryDeleted')\n ->with('Task') // ucfirst('task') = 'Task'\n ->willReturn($queryResult);\n\n self::setPrivateProperty($service, 'queryHandler', $queryHandler);\n\n $this->expectException(InvalidArgumentException::class);\n $this->expectExceptionMessage('Unsupported CrmObject: task');\n\n self::invokePrivateMethod('syncRemotelyDeletedObjects', $service, [CrmObject::TASK]);\n }\n\n public static function crmObjectProvider(): array\n {\n return [\n 'Account' => [CrmObject::ACCOUNT],\n 'Contact' => [CrmObject::CONTACT],\n 'Lead' => [CrmObject::LEAD],\n 'Opportunity' => [CrmObject::OPPORTUNITY],\n ];\n }\n\n public function testVerifyTaskExistsReturnsTrue(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-123', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-123');\n $activity->method('getId')->willReturn(456);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-123', ['Id', 'IsDeleted'])\n ->willReturn(['Id' => 'task-123', 'IsDeleted' => false]);\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertTrue($result);\n }\n\n public function testVerifyTaskExistsReturnsTrueForEvent(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:event-123', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('event-123');\n $activity->method('getId')->willReturn(456);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_EVENT);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Event', 'event-123', ['Id', 'IsDeleted'])\n ->willReturn(['Id' => 'event-123', 'IsDeleted' => false]);\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertTrue($result);\n }\n\n public function testVerifyTaskExistsReturnsFalseWhenDeleted(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-456', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-456');\n $activity->method('getId')->willReturn(789);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-456', ['Id', 'IsDeleted'])\n ->willReturn(['Id' => 'task-456', 'IsDeleted' => true]);\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertFalse($result);\n }\n\n public function testVerifyTaskExistsReturnsFalseWhenNotFound(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-999', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-999');\n $activity->method('getId')->willReturn(999);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-999', ['Id', 'IsDeleted'])\n ->willThrowException(new \\Jiminny\\Exceptions\\HttpNotFoundException('Task not found'));\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertFalse($result);\n }\n\n public function testVerifyTaskExistsReturnsFalseWhenNoPlaybook(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-no-playbook', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-no-playbook');\n $activity->method('getId')->willReturn(111);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn(null);\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertFalse($result);\n }\n\n public function testVerifyTaskExistsThrowsExceptionForTransientErrors(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-error', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-error');\n $activity->method('getId')->willReturn(888);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-error', ['Id', 'IsDeleted'])\n ->willThrowException(new \\RuntimeException('Network timeout'));\n\n $this->expectException(\\RuntimeException::class);\n $this->expectExceptionMessage('Network timeout');\n\n $service->verifyTaskExists($activity);\n }\n\n public function testVerifyTaskExistsCachesResults(): void\n {\n $cachedValue = null;\n Cache::shouldReceive('remember')\n ->twice()\n ->with('crm_task_exists:123:task-cached', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(function ($key, $ttl, $callback) use (&$cachedValue) {\n if ($cachedValue === null) {\n $cachedValue = $callback();\n }\n\n return $cachedValue;\n });\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-cached');\n $activity->method('getId')->willReturn(555);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-cached', ['Id', 'IsDeleted'])\n ->willReturn(['Id' => 'task-cached', 'IsDeleted' => false]);\n\n $result1 = $service->verifyTaskExists($activity);\n $result2 = $service->verifyTaskExists($activity);\n\n $this->assertTrue($result1);\n $this->assertTrue($result2);\n }\n\n public function testVerifyTaskExistsReturnsFalseForHttpBadRequestException(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-400', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-400');\n $activity->method('getId')->willReturn(400);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-400', ['Id', 'IsDeleted'])\n ->willThrowException(new \\Jiminny\\Exceptions\\HttpBadRequestException('Bad request'));\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertFalse($result);\n }\n\n public function testImportOpportunitySkipsWhenNoProfileAndNoAccount(): void\n {\n $crmData = [\n 'Id' => 'SF-NO-USER-1',\n 'Name' => 'Test Opportunity',\n 'OwnerId' => 'owner-no-profile',\n // No AccountId\n ];\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(\\Illuminate\\Events\\Dispatcher::class); // ← ADD THIS\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n\n $service = new Service(\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService\n );\n\n $config = $this->createMock(Configuration::class);\n\n // Mock profiles relation returning null (no profile found)\n $profilesRelation = \\Mockery::mock(\\Illuminate\\Database\\Eloquent\\Relations\\HasMany::class);\n $profilesRelation->shouldReceive('where')->with('crm_provider_id', 'owner-no-profile')->andReturnSelf();\n $profilesRelation->shouldReceive('first')->andReturn(null);\n\n $config->expects($this->once())\n ->method('profiles')\n ->willReturn($profilesRelation);\n\n $team = $this->createMock(Team::class);\n $team->method('getId')->willReturn(1);\n\n $logger = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n $logger->expects($this->once())\n ->method('error')\n ->with(\n '[Salesforce] | Skip import, no user_id found',\n ['id' => 'SF-NO-USER-1']\n );\n\n $reflection = new \\ReflectionClass($service);\n\n $configProperty = $reflection->getProperty('config');\n $configProperty->setAccessible(true);\n $configProperty->setValue($service, $config);\n\n $teamProperty = $reflection->getProperty('team');\n $teamProperty->setAccessible(true);\n $teamProperty->setValue($service, $team);\n\n $loggerProperty = $reflection->getProperty('logger');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($service, $logger);\n\n // Initialize profile property to avoid \"must not be accessed before initialization\" error\n $profileProperty = $reflection->getProperty('profile');\n $profileProperty->setAccessible(true);\n $profileProperty->setValue($service, null);\n\n $result = self::invokePrivateMethod('importOpportunity', $service, [$crmData]);\n\n $this->assertNull($result);\n }\n\n public function testImportOpportunitySkipsWhenProfileNotFound(): void\n {\n $crmData = [\n 'Id' => 'SF-NO-USER-2',\n 'Name' => 'Test Opportunity',\n 'OwnerId' => 'owner-not-found',\n // No AccountId - avoid complex account processing\n ];\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $eventDispatcher = $this->createMock(\\Illuminate\\Events\\Dispatcher::class); // ← ADD THIS\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n\n\n $service = new Service(\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService\n );\n\n $config = $this->createMock(Configuration::class);\n\n // Mock profiles relation returning null (no profile found)\n $profilesRelation = \\Mockery::mock(\\Illuminate\\Database\\Eloquent\\Relations\\HasMany::class);\n $profilesRelation->shouldReceive('where')->with('crm_provider_id', 'owner-not-found')->andReturnSelf();\n $profilesRelation->shouldReceive('first')->andReturn(null);\n\n $config->expects($this->once())\n ->method('profiles')\n ->willReturn($profilesRelation);\n\n $team = $this->createMock(Team::class);\n $team->method('getId')->willReturn(1);\n\n $logger = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n $logger->expects($this->once())\n ->method('error')\n ->with(\n '[Salesforce] | Skip import, no user_id found',\n ['id' => 'SF-NO-USER-2']\n );\n\n $reflection = new \\ReflectionClass($service);\n\n $configProperty = $reflection->getProperty('config');\n $configProperty->setAccessible(true);\n $configProperty->setValue($service, $config);\n\n $teamProperty = $reflection->getProperty('team');\n $teamProperty->setAccessible(true);\n $teamProperty->setValue($service, $team);\n\n $loggerProperty = $reflection->getProperty('logger');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($service, $logger);\n\n // Initialize profile property\n $profileProperty = $reflection->getProperty('profile');\n $profileProperty->setAccessible(true);\n $profileProperty->setValue($service, null);\n\n $result = self::invokePrivateMethod('importOpportunity', $service, [$crmData]);\n\n $this->assertNull($result);\n }\n}\n\nclass HasManyExtended extends HasMany\n{\n public function where()\n {\n }\n\n public function first()\n {\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"A","depth":4,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":5,"bounds":{"left":0.27027926,"top":1.0,"width":0.0039893617,"height":0.0},"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Select All","depth":4,"bounds":{"left":0.42652926,"top":0.14365523,"width":0.09009308,"height":0.0207502},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"320","depth":6,"bounds":{"left":0.5169548,"top":0.16520351,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"320","depth":7,"bounds":{"left":0.5169548,"top":0.16520351,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"5772","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"5772","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"46ea38f7-c940-395d-9c79-0329d30e9ab7","depth":6,"bounds":{"left":0.5169548,"top":0.1867518,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"46ea38f7-c940-395d-9c79-0329d30e9ab7","depth":7,"bounds":{"left":0.5169548,"top":0.1867518,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"2024-11-26 09:21:15","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"2024-11-26 09:21:15","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"401","depth":6,"bounds":{"left":0.5169548,"top":0.20830008,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"401","depth":7,"bounds":{"left":0.5169548,"top":0.20830008,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"5772","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"5772","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"<null>","depth":6,"bounds":{"left":0.5169548,"top":0.22984837,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"<null>","depth":7,"bounds":{"left":0.5169548,"top":0.22984837,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"5772","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"5772","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"salesforce","depth":6,"bounds":{"left":0.5169548,"top":0.25139666,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"salesforce","depth":7,"bounds":{"left":0.5169548,"top":0.25139666,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"2024-11-26 09:21:15","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"2024-11-26 09:21:15","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"enterprise","depth":6,"bounds":{"left":0.5169548,"top":0.27294493,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"enterprise","depth":7,"bounds":{"left":0.5169548,"top":0.27294493,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"2024-11-26 09:21:15","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"2024-11-26 09:21:15","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"SWE38","depth":6,"bounds":{"left":0.5169548,"top":0.29449323,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"SWE38","depth":7,"bounds":{"left":0.5169548,"top":0.29449323,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"2024-11-26 09:21:15","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"2024-11-26 09:21:15","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"0","depth":6,"bounds":{"left":0.5169548,"top":0.3160415,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"0","depth":7,"bounds":{"left":0.5169548,"top":0.3160415,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"5772","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"5772","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"50.0","depth":6,"bounds":{"left":0.5169548,"top":0.33758977,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"50.0","depth":7,"bounds":{"left":0.5169548,"top":0.33758977,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"2024-11-26 09:21:15","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"2024-11-26 09:21:15","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"01801","depth":6,"bounds":{"left":0.5169548,"top":0.35913807,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"01801","depth":7,"bounds":{"left":0.5169548,"top":0.35913807,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"2024-11-26 09:21:15","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"2024-11-26 09:21:15","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"1","depth":6,"bounds":{"left":0.5169548,"top":0.38068634,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"1","depth":7,"bounds":{"left":0.5169548,"top":0.38068634,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"5772","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"5772","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"1","depth":6,"bounds":{"left":0.5169548,"top":0.40223464,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"1","depth":7,"bounds":{"left":0.5169548,"top":0.40223464,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"5772","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"5772","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"1","depth":6,"bounds":{"left":0.5169548,"top":0.4237829,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"1","depth":7,"bounds":{"left":0.5169548,"top":0.4237829,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"5772","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"5772","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"00D20000000MdbgEAC","depth":6,"bounds":{"left":0.5169548,"top":0.44533122,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"00D20000000MdbgEAC","depth":7,"bounds":{"left":0.5169548,"top":0.44533122,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"2024-11-26 09:21:15","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"2024-11-26 09:21:15","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"https://bipsolutions.my.salesforce.com","depth":6,"bounds":{"left":0.5169548,"top":0.4668795,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"https://bipsolutions.my.salesforce.com","depth":7,"bounds":{"left":0.5169548,"top":0.4668795,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"2024-11-26 09:21:15","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"2024-11-26 09:21:15","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"2026-03-19 08:19:24","depth":6,"bounds":{"left":0.5169548,"top":0.4884278,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"2026-03-19 08:19:24","depth":7,"bounds":{"left":0.5169548,"top":0.4884278,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"2024-11-26 09:21:15","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"2024-11-26 09:21:15","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"2026-05-22 09:48:21","depth":6,"bounds":{"left":0.5169548,"top":0.509976,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"2026-05-22 09:48:21","depth":7,"bounds":{"left":0.5169548,"top":0.509976,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"2024-11-26 09:21:15","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"2024-11-26 09:21:15","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"2026-05-22 09:48:31","depth":6,"bounds":{"left":0.5169548,"top":0.53152436,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"2026-05-22 09:48:31","depth":7,"bounds":{"left":0.5169548,"top":0.53152436,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"2024-11-26 09:21:15","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"2024-11-26 09:21:15","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"2026-05-22 09:48:43","depth":6,"bounds":{"left":0.5169548,"top":0.55307263,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"2026-05-22 09:48:43","depth":7,"bounds":{"left":0.5169548,"top":0.55307263,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"2024-11-26 09:21:15","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"2024-11-26 09:21:15","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"2026-05-22 09:48:29","depth":6,"bounds":{"left":0.5169548,"top":0.5746209,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"2026-05-22 09:48:29","depth":7,"bounds":{"left":0.5169548,"top":0.5746209,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"2024-11-26 09:21:15","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"2024-11-26 09:21:15","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"2026-05-22 09:48:40","depth":6,"bounds":{"left":0.5169548,"top":0.5961692,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"2026-05-22 09:48:40","depth":7,"bounds":{"left":0.5169548,"top":0.5961692,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"2024-11-26 09:21:15","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"2024-11-26 09:21:15","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"<null>","depth":6,"bounds":{"left":0.5169548,"top":0.6177175,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"<null>","depth":7,"bounds":{"left":0.5169548,"top":0.6177175,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"2024-11-26 09:21:15","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"2024-11-26 09:21:15","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"<null>","depth":6,"bounds":{"left":0.5169548,"top":0.6392658,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"<null>","depth":7,"bounds":{"left":0.5169548,"top":0.6392658,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"2024-11-26 09:21:15","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"2024-11-26 09:21:15","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"{\"opportunity_sync_period\":\"3m\",\"opportunity_sync_strategies\":[\"lastModified\"]}","depth":6,"bounds":{"left":0.5169548,"top":0.66081405,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"{\"opportunity_sync_period\":\"3m\",\"opportunity_sync_strategies\":[\"lastModified\"]}","depth":7,"bounds":{"left":0.5169548,"top":0.66081405,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"{\"opportunity_sync_period\":\"3m\",\"opp","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"{\"opportunity_sync_period\":\"3m\",\"opp","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"open-recently-updated","depth":6,"bounds":{"left":0.5169548,"top":0.6823623,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"open-recently-updated","depth":7,"bounds":{"left":0.5169548,"top":0.6823623,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"2024-11-26 09:21:15","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"2024-11-26 09:21:15","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"250000000.00","depth":6,"bounds":{"left":0.5169548,"top":0.7039106,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"250000000.00","depth":7,"bounds":{"left":0.5169548,"top":0.7039106,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"5772","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"5772","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"5772","depth":6,"bounds":{"left":0.5169548,"top":0.7254589,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"5772","depth":7,"bounds":{"left":0.5169548,"top":0.7254589,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"5772","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"5772","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"<null>","depth":6,"bounds":{"left":0.5169548,"top":0.7470072,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"<null>","depth":7,"bounds":{"left":0.5169548,"top":0.7470072,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"5772","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"5772","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"1","depth":6,"bounds":{"left":0.5169548,"top":0.76855546,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"1","depth":7,"bounds":{"left":0.5169548,"top":0.76855546,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"5772","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"5772","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"0","depth":6,"bounds":{"left":0.5169548,"top":0.79010373,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"0","depth":7,"bounds":{"left":0.5169548,"top":0.79010373,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"5772","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"5772","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"0","depth":6,"bounds":{"left":0.5169548,"top":0.81165206,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"}]...
|
7732332105235147404
|
-6520523286572803295
|
idle
|
accessibility
|
NULL
|
Project: faVsco.js, menu
JY-20963-fix-import-on-de Project: faVsco.js, menu
JY-20963-fix-import-on-deleted-entity, menu
Start Listening for PHP Debug Connections
ServiceTest
Rerun 'PHPUnit: ServiceTest'
Debug 'ServiceTest'
Stop 'ServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Built-in Preview
Chrome
Firefox
Safari
Sync Changes
Hide This Notification
Code changed:
Hide
4
32
168
1
28
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Tests\Unit\Services\Crm\Salesforce;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Events\Dispatcher;
use Illuminate\Support\Facades\Cache;
use Jiminny\Component\Country\CountriesMap;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Enums\CrmObject;
use Jiminny\Exceptions\InvalidArgumentException;
use Jiminny\Exceptions\NoResultsException;
use Jiminny\Integrations\PlaybookResolver;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Playbook;
use Jiminny\Models\Team;
use Jiminny\Models\TeamSettings;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\ContactRoleRepository;
use Jiminny\Repositories\Crm\FieldDataRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\Salesforce\Client;
use Jiminny\Services\Crm\Salesforce\PayloadBuilder;
use Jiminny\Services\Crm\Salesforce\QueryBuilder;
use Jiminny\Services\Crm\Salesforce\QueryHandler;
use Jiminny\Services\Crm\Salesforce\QueryIterator;
use Jiminny\Services\Crm\Salesforce\QueryResults;
use Jiminny\Services\Crm\Salesforce\Service;
use PHPUnit\Framework\MockObject\MockObject;
use Tests\TestCase;
use Tests\Unit\Traits\TestPrivateMethod;
class ServiceTest extends TestCase
{
use TestPrivateMethod;
public function testFetchAndAssociateRelatedActivity(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$payloadBuilder->method('addCustomLogicFieldsPayload')
->willReturnCallback(function ($activity, $payload) {
return $payload;
});
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods(['fetchRelatedActivity', 'getPlaybook', 'getPlaybookCategory', 'updateRecord'])
->getMock();
$serviceMock->expects($this->once())
->method('fetchRelatedActivity')
->willReturn([
'Id' => 'testId',
'Type' => null,
'OwnerId' => 'testerUser',
'Description' => 'Test description',
]);
$user = $this->createMock(User::class);
$team = $this->createMock(Team::class);
$user->method('getAttribute')->with('team')->willReturn($team);
$playbook = $this->createMock(Playbook::class);
$playbook->method('getActivityField')->willReturn(null);
$playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_EVENT);
$serviceMock->expects($this->once())
->method('getPlaybook')
->with($user)
->willReturn($playbook);
$serviceMock->expects($this->never())
->method('getPlaybookCategory');
$serviceMock->expects($this->never())
->method('updateRecord');
$fieldDataRepository = $this->createMock(FieldDataRepository::class);
$fieldDataRepository->method('getActivityFieldData')->willReturn(collect([]));
app()->instance(FieldDataRepository::class, $fieldDataRepository);
$config = $this->createMock(Configuration::class);
$profilesRelation = $this->getMockBuilder(\Illuminate\Database\Eloquent\Relations\HasMany::class)
->disableOriginalConstructor()
->onlyMethods(['get'])
->addMethods(['where'])
->getMock();
$profilesRelation->method('where')->willReturnSelf();
$profilesRelation->method('get')->willReturn(collect([]));
$config->method('profiles')->willReturn($profilesRelation);
$serviceMock->config = $config;
$serviceMock->profile = null;
$actualStartTime = \Carbon\Carbon::now();
$activity = $this->getMockBuilder(Activity::class)
->disableOriginalConstructor()
->onlyMethods(['update', 'hasProspect'])
->getMock();
$activity->method('update')->willReturn(true);
$activity->method('hasProspect')->willReturn(true);
$activity->type = Activity::TYPE_CONFERENCE;
$activity->provider = Activity::PROVIDER_TWILIO;
$activity->lead_id = 1;
$activity->user_id = 0;
$activity->id_string = 'test-activity-id';
$activity->user = $user;
$activity->actual_start_time = $actualStartTime;
$activity->uuid = 'c53d8320-f556-4cee-a2f8-5f232f454ca4';
app()->bind(PlaybookResolver::class, function () use ($user) {
$playbook = $this->createMock(Playbook::class);
$playbookResolver = $this->createMock(PlaybookResolver::class);
$playbookResolver->expects($this->once())
->method('resolvePlaybookByUser')
->with($user)
->willReturn($playbook);
return $playbookResolver;
});
$data = $serviceMock->fetchAndAssociateRelatedActivity($activity);
$this->assertInstanceOf(Activity::class, $data);
$this->assertEquals(Activity::TYPE_CONFERENCE, $data->getType());
$this->assertEquals($actualStartTime->getTimestamp(), $data->getActualStartTime()->getTimestamp());
}
public function testFetchAndAssociateRelatedActivitySkipsForTaskBasedPlaybook(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods(['fetchRelatedActivity', 'getPlaybook'])
->getMock();
$user = $this->createMock(User::class);
$playbook = $this->createMock(Playbook::class);
$playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);
$playbook->method('getId')->willReturn(123);
$serviceMock->expects($this->once())
->method('getPlaybook')
->with($user)
->willReturn($playbook);
$serviceMock->expects($this->never())
->method('fetchRelatedActivity');
$activity = $this->getMockBuilder(Activity::class)
->disableOriginalConstructor()
->onlyMethods(['hasProspect', 'getUuid'])
->getMock();
$activity->method('hasProspect')->willReturn(true);
$activity->method('getUuid')->willReturn('c53d8320-f556-4cee-a2f8-5f232f454ca4');
$activity->type = Activity::TYPE_CONFERENCE;
$activity->actual_start_time = \Carbon\Carbon::now();
$activity->user = $user;
$result = $serviceMock->fetchAndAssociateRelatedActivity($activity);
$this->assertNull($result);
}
public function testFetchAndAssociateRelatedActivityReturnsNullForNonConference(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$serviceMock = new Service(
client: $client,
payloadBuilder: $payloadBuilder,
eventDispatcher: $eventDispatcher,
countriesMap: $countriesMap,
prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)
);
$activity = $this->getMockBuilder(Activity::class)
->disableOriginalConstructor()
->getMock();
$activity->type = Activity::TYPE_SOFTPHONE;
$result = $serviceMock->fetchAndAssociateRelatedActivity($activity);
$this->assertNull($result);
}
public function testFetchAndAssociateRelatedActivityReturnsNullWhenNoStartTime(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$serviceMock = new Service(
client: $client,
payloadBuilder: $payloadBuilder,
eventDispatcher: $eventDispatcher,
countriesMap: $countriesMap,
prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)
);
$activity = $this->getMockBuilder(Activity::class)
->disableOriginalConstructor()
->getMock();
$activity->type = Activity::TYPE_CONFERENCE;
$activity->actual_start_time = null;
$activity->scheduled_start_time = null;
$result = $serviceMock->fetchAndAssociateRelatedActivity($activity);
$this->assertNull($result);
}
public function testFetchAndAssociateRelatedActivityReturnsNullWhenNoProspect(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods(['getPlaybook'])
->getMock();
$serviceMock->expects($this->never())
->method('getPlaybook');
$activity = $this->getMockBuilder(Activity::class)
->disableOriginalConstructor()
->onlyMethods(['hasProspect', 'getUuid'])
->getMock();
$activity->method('hasProspect')->willReturn(false);
$activity->method('getUuid')->willReturn('c53d8320-f556-4cee-a2f8-5f232f454ca4');
$activity->type = Activity::TYPE_CONFERENCE;
$activity->actual_start_time = \Carbon\Carbon::now();
$result = $serviceMock->fetchAndAssociateRelatedActivity($activity);
$this->assertNull($result);
}
public function testMatchExactlyByEmail(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods([])
->getMock();
$profile = new Profile();
$profile->setAttribute('id', bin2hex(random_bytes(8)));
$serviceMock->profile = $profile;
$team = $this->createMock(Team::class);
$serviceMock->team = $team;
$data = $serviceMock->matchExactlyByEmail(bin2hex(random_bytes(8)) . '[EMAIL]');
$this->assertEquals(null, $data);
}
public function testMatchDomainFromEmail(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$queryIterator = $this->createMock(QueryIterator::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$config = $this->createMock(Configuration::class);
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('search')
->willReturn($queryIterator);
return $handler;
});
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods(['convertCrmData'])
->getMock();
$profile = new Profile();
$profile->account_fields = 'Field1, Field2, Field3';
$serviceMock->profile = $profile;
$serviceMock->expects($this->once())
->method('convertCrmData')
->willReturn(['test']);
$this->app->bind(QueryBuilder::class, function () {
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->expects($this->once())
->method('buildMatchByDomainQuery')
->with('[EMAIL]')
->willReturn('FIND {[EMAIL]} IN ALL FIELDS RETURNING Account(Id)');
return $queryBuilder;
});
$team = $this->createMock(Team::class);
$serviceMock->team = $team;
$serviceMock->config = $config;
$data = $serviceMock->matchByDomain('[EMAIL]');
$this->assertEquals(['test'], $data);
}
public function testBuildTaskSearchFields(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$service = new Service(
client: $client,
payloadBuilder: $payloadBuilder,
eventDispatcher: $eventDispatcher,
countriesMap: $countriesMap,
prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)
);
$fields = $service->buildTaskSearchFields();
$expectedFields = ['Id', 'WhoId', 'WhatId', 'AccountId'];
$this->assertEquals($expectedFields, $fields);
}
public function testMapCrmObjects(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$service = new Service(
client: $client,
payloadBuilder: $payloadBuilder,
eventDispatcher: $eventDispatcher,
countriesMap: $countriesMap,
prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class),
);
$sampleTask = [
'WhoId' => '003sampleWhoId',
'AccountId' => 'sampleAccountId',
'WhatId' => 'sampleWhatId',
];
$activityData = $service->mapCrmObjects($sampleTask);
$expectedActivityData = [
'contact' => '003sampleWhoId',
'account' => 'sampleAccountId',
'opportunity' => 'sampleWhatId',
];
$this->assertEquals($expectedActivityData, $activityData);
}
public function testGetInstalledAppVersion(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$queryIterator = $this->createMock(QueryIterator::class);
$queryIterator->expects($this->any())
->method('current')->willReturn([
'SubscriberPackageVersion' => [
'MajorVersion' => '1',
'MinorVersion' => '0',
'PatchVersion' => '1',
'BuildNumber' => '0',
],
]);
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('metadata')
->willReturn($queryIterator);
return $handler;
});
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods(array_diff(get_class_methods(Service::class), ['getInstalledAppVersion']))
->getMock();
$version = $serviceMock->getInstalledAppVersion();
$this->assertEquals('1010', $version);
}
public function testSyncProfiles(): void
{
$userToSearch = null;
$team = $this->createMock(Team::class);
$config = $this->createMockedConfiguration();
$config->expects($this->once())
->method('getId')
->willReturn(1);
$salesforceUser = [
'Email' => '[EMAIL]',
'UserPreferencesLightningExperiencePreferred' => true,
'CallCenterId' => '123',
'Id' => '456',
'ProfileId' => '789',
];
app()->bind(QueryBuilder::class, function () use ($userToSearch) {
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->expects($this->once())
->method('buildGetUsersQuery')
->with($userToSearch)
->willReturn('SELECT * FROM Users');
return $queryBuilder;
});
$queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('query')
->with('SELECT * FROM Users')
->willReturn($queryIterator);
return $handler;
});
$user = $this->createMock(User::class);
$user->expects($this->once())
->method('getId')
->willReturn(123);
$this->mockTeamRepository($team, $salesforceUser, $user);
$profileRepository = $this->createMock(ProfileRepository::class);
$profileRepository->expects($this->once())
->method('updateOrCreateProfile')
->with(
$user,
[
'crm_configuration_id' => 1,
'crm_provider_id' => '456',
],
[
'user_id' => 123,
'edition' => Profile::EDITION_LIGHTNING,
'has_external_cti' => true,
'crm_profile_id' => '789',
]
)
->willReturn(new Profile());
$this->app->instance(ProfileRepository::class, $profileRepository);
$serviceMock = $this->getServiceMock();
$serviceMock->team = $team;
$serviceMock->config = $config;
$result = $serviceMock->syncProfiles($userToSearch);
$this->assertNull($result);
}
public function testSyncProfilesEmailIsNull(): void
{
$userToSearch = $this->createMock(User::class);
$salesforceUser = [
'Email' => null,
];
app()->bind(QueryBuilder::class, function () {
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->expects($this->once())
->method('buildGetUsersQuery')
->with(null)
->willReturn('SELECT * FROM Users');
return $queryBuilder;
});
$queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->exactly(2))
->method('query')
->willReturn($queryIterator);
return $handler;
});
$team = $this->createMock(Team::class);
$user = $this->createMock(User::class);
$this->mockTeamRepository($team, $salesforceUser, $user, false);
$config = $this->createMock(Configuration::class);
$serviceMock = $this->getServiceMock();
$serviceMock->team = $team;
$serviceMock->config = $config;
$profile = $serviceMock->syncProfiles(null);
$this->assertNull($profile);
}
public function testSyncProfilesUserToSearchMatchesCurrentUser(): void
{
$userToSearch = $this->createMock(User::class);
$userToSearch->expects($this->once())
->method('getId')
->willReturn(123);
$team = $this->createMock(Team::class);
$config = $this->createMock(Configuration::class);
$config->expects($this->once())
->method('getId')
->willReturn(1);
$salesforceUser = [
'Email' => '[EMAIL]',
'UserPreferencesLightningExperiencePreferred' => true,
'CallCenterId' => '123',
'Id' => '456',
'ProfileId' => '789',
];
app()->bind(QueryBuilder::class, function () use ($userToSearch) {
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->expects($this->once())
->method('buildGetUsersQuery')
->with($userToSearch)
->willReturn('SELECT * FROM Users');
return $queryBuilder;
});
$queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('query')
->with('SELECT * FROM Users')
->willReturn($queryIterator);
return $handler;
});
$user = $this->createMock(User::class);
$user->expects($this->exactly(2))
->method('getId')
->willReturn(123);
$this->mockTeamRepository($team, $salesforceUser, $user);
$profileRepository = $this->createMock(ProfileRepository::class);
$profileRepository->expects($this->once())
->method('updateOrCreateProfile')
->with(
$user,
[
'crm_configuration_id' => 1,
'crm_provider_id' => '456',
],
[
'user_id' => 123,
'edition' => Profile::EDITION_LIGHTNING,
'has_external_cti' => true,
'crm_profile_id' => '789',
]
)
->willReturn(new Profile());
$this->app->instance(ProfileRepository::class, $profileRepository);
$serviceMock = $this->getServiceMock();
$serviceMock->team = $team;
$serviceMock->config = $config;
$profile = $serviceMock->syncProfiles($userToSearch);
$this->assertInstanceOf(Profile::class, $profile);
}
public function testSyncProfilesWithCustomValidation(): void
{
$userToSearch = null;
$team = $this->createMock(Team::class);
$config = $this->createMockedConfiguration();
$config->expects($this->atLeastOnce()) // Changed from once() to atLeastOnce()
->method('getId')
->willReturn(1);
$salesforceUser = [
'Email' => '[EMAIL]',
'UserPreferencesLightningExperiencePreferred' => true,
'CallCenterId' => '123',
'Id' => '456',
'ProfileId' => '789',
'CustomField' => 'CustomValue',
];
$customRules = [
['field' => 'CustomField', 'value' => 'CustomValue'],
];
$this->mockQueryBuilderAndHandler($userToSearch, [$salesforceUser]);
$user = $this->createMock(User::class);
$user->expects($this->once())
->method('getId')
->willReturn(123);
$this->mockTeamRepository($team, $salesforceUser, $user, true, $customRules);
$this->mockProfileRepository($user);
$serviceMock = $this->getServiceMock();
$serviceMock->team = $team;
$serviceMock->config = $config;
$profile = $serviceMock->syncProfiles($userToSearch);
$this->assertNull($profile);
}
public function testSyncProfilesWithCustomValidationFailing(): void
{
$userToSearch = null;
$team = $this->createMock(Team::class);
$config = $this->createMockedConfiguration();
$salesforceUser = [
'Email' => '[EMAIL]',
'UserPreferencesLightningExperiencePreferred' => true,
'CallCenterId' => '123',
'Id' => '456',
'ProfileId' => '789',
'CustomField' => 'WrongValue',
];
$customRules = [
['field' => 'CustomField', 'value' => 'CustomValue'],
];
$this->mockQueryBuilderAndHandler($userToSearch, [$salesforceUser]);
$teamRepository = $this->getMockForAbstractClass(TeamRepository::class, [], '', false, true, true, ['findActiveTeamMemberByEmail', 'getTeamSetting']);
$teamSettings = $this->createMock(TeamSettings::class);
$teamSettings->method('getValueType')
->willReturn('array');
$teamSettings->method('getValue')
->willReturn(json_encode($customRules));
$teamRepository->expects($this->once())
->method('getTeamSetting')
->with($team, 'custom_profile_validation')
->willReturn($teamSettings);
app()->bind(TeamRepository::class, function () use ($teamRepository) {
return $teamRepository;
});
$profileRepository = $this->createMock(ProfileRepository::class);
$profileRepository->expects($this->never())
->method('updateOrCreateProfile');
$this->app->instance(ProfileRepository::class, $profileRepository);
$serviceMock = $this->getServiceMock();
$serviceMock->team = $team;
$serviceMock->config = $config;
$result = $serviceMock->syncProfiles($userToSearch);
$this->assertNull($result);
}
public function testGetContactRolesFromCrm(): void
{
$contactRoles = [
[
'Id' => '1',
'ContactId' => 'Contact1',
'OpportunityId' => 'Opportunity1',
'Opportunity' => ['OwnerId' => 'Owner1'],
'IsPrimary' => true,
'Role' => 'Decision Maker',
],
];
$expectedResponse = [
[
'id' => '1',
'contactId' => 'Contact1',
'opportunityId' => 'Opportunity1',
'ownerId' => 'Owner1',
'isPrimary' => true,
'role' => 'Decision Maker',
],
];
$this->bindQueryIterator($contactRoles);
$serviceMock = $this->getServiceMock();
$data = $serviceMock->getContactRolesFromCrm(now()->subDay());
$this->assertEquals($expectedResponse, $data);
}
public function testGetContactRolesFromCrmNoResult(): void
{
$this->bindQueryIterator([]);
$serviceMock = $this->getServiceMock();
$data = $serviceMock->getContactRolesFromCrm(now()->subDay());
$this->assertEquals([], $data);
}
public function testSyncContactRoles(): void
{
$contactRoles = [
[
'id' => '1',
'contactId' => 'Contact1',
'opportunityId' => 'Opportunity1',
'ownerId' => 'Owner1',
'isPrimary' => true,
'role' => 'Decision Maker',
],
];
app()->bind(ContactRoleRepository::class, function () {
$contactRoleRepository = $this->createMock(ContactRoleRepository::class);
$contactRoleRepository->expects($this->once())
->method('saveContactRoles');
return $contactRoleRepository;
});
$serviceMock = $this->getServiceMock([
'getContactRolesFromCrm',
'syncRemotelyDeletedContactRoles',
'syncContact',
'syncOpportunity',
]);
$config = $this->createMock(Configuration::class);
$hasMany = $this->createMock(HasManyExtended::class);
$hasMany->expects($this->exactly(2))
->method('where')
->willReturn($hasMany);
$hasMany->expects($this->exactly(2))
->method('first')
->willReturn(
$this->createMock(Contact::class),
$this->createMock(Opportunity::class)
);
$config->expects($this->once())
->method('contacts')
->willReturn($hasMany);
$config->expects($this->once())
->method('opportunities')
->willReturn($hasMany);
$serviceMock->config = $config;
$serviceMock->expects($this->once())
->method('getContactRolesFromCrm')
->willReturn($contactRoles);
$serviceMock->expects($this->once())
->method('syncRemotelyDeletedContactRoles');
$data = $serviceMock->syncContactRoles(now()->subDay());
$this->assertEquals(1, $data);
}
public function testSyncRemotelyDeletedContactRoles(): void
{
$contactRoles = [
[
'id' => '1',
'crm_provider_id' => '1',
],
];
app()->bind(QueryHandler::class, function () use ($contactRoles) {
$queryResults = new QueryResults($contactRoles, 1, true, null);
$handler = $this->createMock(QueryHandler::class);
$handler->method('queryDeleted')
->willReturn($queryResults);
return $handler;
});
app()->bind(ContactRoleRepository::class, function () {
$contactRoleRepository = $this->createMock(ContactRoleRepository::class);
$contactRoleRepository->expects($this->once())
->method('deleteContactRoles');
return $contactRoleRepository;
});
$serviceMock = $this->getServiceMock();
$serviceMock->team = $this->createMock(Team::class);
$data = $this->invokePrivateMethod('syncRemotelyDeletedContactRoles', $serviceMock, []);
$this->assertTrue($data);
}
private function bindQueryIterator(array $queryResult): void
{
/** @var Client $client */
$client = $this->createMock(Client::class);
$queryIterator = new QueryIterator(
$client,
new QueryResults($queryResult, 1, true, null)
);
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('query')
->willReturn($queryIterator);
return $handler;
});
}
public static function getOpportunitySortOrderDataProvider(): array
{
return [
'all open recently updated' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED, ['LastModifiedDate DESC', true]],
'all open recently created' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED, ['CreatedDate DESC', true]],
'all open oldest created' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED, ['CreatedDate ASC', true]],
'all recently updated' => [Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED, ['LastModifiedDate DESC', false]],
'default' => ['unknown', ['LastModifiedDate DESC', true]],
];
}
private function createMockedConfiguration(): Configuration
{
$config = $this->createMock(Configuration::class);
$profilesRelation = $this->getMockBuilder(\Illuminate\Database\Eloquent\Relations\HasMany::class)
->disableOriginalConstructor()
->onlyMethods(['get'])
->addMethods(['where', 'first'])
->getMock();
$profilesRelation->method('where')->willReturnSelf();
$profilesRelation->method('get')->willReturn(collect([]));
$profilesRelation->method('first')->willReturn(null);
$config->method('profiles')->willReturn($profilesRelation);
return $config;
}
private function getServiceMock(array $onlyMethods = []): MockObject&Service
{
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$this->createMock(Client::class),
$this->createMock(PayloadBuilder::class),
$this->createMock(Dispatcher::class),
$this->createMock(CountriesMap::class),
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods($onlyMethods)
->getMock();
$serviceMock->profile = $this->createMock(Profile::class);
return $serviceMock;
}
private function mockQueryBuilderAndHandler($userToSearch, $salesforceUsers): void
{
app()->bind(QueryBuilder::class, function () use ($userToSearch) {
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->expects($this->once())
->method('buildGetUsersQuery')
->with($userToSearch)
->willReturn('SELECT * FROM Users');
return $queryBuilder;
});
$queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults($salesforceUsers, count($salesforceUsers), true, null));
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('query')
->willReturn($queryIterator);
return $handler;
});
}
private function mockTeamRepository(
Team $team,
array $salesforceUser,
?User $user = null,
bool $userSearch = true,
array $customRules = []
): void {
$teamRepository = $this->getMockForAbstractClass(TeamRepository::class, [], '', false, true, true, ['findActiveTeamMemberByEmail', 'getTeamSetting']);
if ($userSearch) {
$teamRepository->expects($this->once())
->method('findActiveTeamMemberByEmail')
->with($team, $salesforceUser['Email'])
->willReturn($user);
}
$teamSettings = $this->createMock(TeamSettings::class);
$teamSettings->method('getValueType')
->willReturn('array');
$teamSettings->method('getValue')
->willReturn(json_encode($customRules));
$teamRepository->expects($this->once())
->method('getTeamSetting')
->with($team, 'custom_profile_validation')
->willReturn($teamSettings);
app()->bind(TeamRepository::class, function () use ($teamRepository) {
return $teamRepository;
});
}
private function mockProfileRepository(User $user): void
{
$profileRepository = $this->createMock(ProfileRepository::class);
$profileRepository->expects($this->once())
->method('updateOrCreateProfile')
->with(
$user,
[
'crm_configuration_id' => 1,
'crm_provider_id' => '456',
],
[
'user_id' => 123,
'edition' => Profile::EDITION_LIGHTNING,
'has_external_cti' => true,
'crm_profile_id' => '789',
]
)
->willReturn(new Profile());
$this->app->instance(ProfileRepository::class, $profileRepository);
}
public function testBuildEnhancedNoteDecodesHtmlEntities(): void
{
$service = $this->getServiceMock(['createRecord']);
$profile = new Profile();
$profile->setAttribute('crm_provider_id', 'owner-123');
$service->profile = $profile;
$service->expects($this->exactly(2))
->method('createRecord')
->willReturnOnConsecutiveCalls('note-id-123', 'link-id-456');
$bodyWithEntities = 'Welch's current challenges and Facebook's Club';
$result = $this->invokePrivateMethod('buildEnhancedNote', $service, [
'Test Title',
$bodyWithEntities,
'object-id-789',
]);
$this->assertEquals('note-id-123', $result);
}
public function testBuildEnhancedNoteSanitizesWithoutQuotes(): void
{
$service = $this->getServiceMock(['createRecord']);
$profile = new Profile();
$profile->setAttribute('crm_provider_id', 'owner-456');
$service->profile = $profile;
$service->expects($this->exactly(2))
->method('createRecord')
->willReturnCallback(function ($type, $data) {
if ($type === 'ContentNote') {
$decoded = base64_decode($data['Content']);
$this->assertStringContainsString("Welch's", $decoded);
$this->assertStringNotContainsString(''', $decoded);
$this->assertStringNotContainsString('&#039;', $decoded);
$this->assertStringContainsString('<script>', $decoded);
return 'note-id-456';
}
return 'link-id-789';
});
$bodyWithMixedContent = "Welch's and <script>alert('xss')</script>";
$result = $this->invokePrivateMethod('buildEnhancedNote', $service, [
'Test Title',
$bodyWithMixedContent,
'object-id-123',
]);
$this->assertEquals('note-id-456', $result);
}
public function testBuildEnhancedNoteConvertsLineBreaks(): void
{
$service = $this->getServiceMock(['createRecord']);
$profile = new Profile();
$profile->setAttribute('crm_provider_id', 'owner-789');
$service->profile = $profile;
$service->expects($this->exactly(2))
->method('createRecord')
->willReturnCallback(function ($type, $data) {
if ($type === 'ContentNote') {
$decoded = base64_decode($data['Content']);
$this->assertStringContainsString('<br>', $decoded);
$this->assertStringNotContainsString('<br />', $decoded);
return 'note-id-789';
}
return 'link-id-012';
});
$bodyWithLineBreaks = "Line 1\nLine 2\nLine 3";
$result = $this->invokePrivateMethod('buildEnhancedNote', $service, [
'Test Title',
$bodyWithLineBreaks,
'object-id-456',
]);
$this->assertEquals('note-id-789', $result);
}
public function testBuildEnhancedNoteHandlesComplexScenario(): void
{
$service = $this->getServiceMock(['createRecord']);
$profile = new Profile();
$profile->setAttribute('crm_provider_id', 'owner-complex');
$service->profile = $profile;
$service->expects($this->exactly(2))
->method('createRecord')
->willReturnCallback(function ($type, $data) {
if ($type === 'ContentNote') {
$decoded = base64_decode($data['Content']);
$this->assertStringContainsString("Welch's", $decoded);
$this->assertStringContainsString("Facebook's Club", $decoded);
$this->assertStringContainsString("Arctics'", $decoded);
$this->assertStringNotContainsString(''', $decoded);
$this->assertStringNotContainsString('&#039;', $decoded);
$this->assertStringContainsString('<br>', $decoded);
$this->assertStringContainsString('<', $decoded);
$this->assertStringContainsString('>', $decoded);
return 'note-complex';
}
return 'link-complex';
});
$complexBody = "Summary:\n---------\nThe call focused on understanding Welch's current challenges and exploring how Arctics' Revenue Growth Management solutions could support their strategic goals.\n\n• John SMith discussed his role as a category advisor for Google and Facebook's Club, emphasizing the importance of market research and advising on product assortment.\n• Madona introduced Arctics' Virtual Shoppers AI, which simulates consumer <behavior> to optimize pricing and promotional strategies.";
$result = $this->invokePrivateMethod('buildEnhancedNote', $service, [
'Jiminny Transcription Summary',
$complexBody,
'task-id-001',
]);
$this->assertEquals('note-complex', $result);
}
public function testSyncRemotelyDeletedObjectsWithErrorHandlingSuccess(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$prospectPhotoPathService,
])
->onlyMethods(['syncRemotelyDeletedObjects'])
->getMock();
// Mock team
$team = $this->createMock(Team::class);
$team->method('getUuid')->willReturn('team-uuid-123');
$serviceMock->team = $team;
// Expect syncRemotelyDeletedObjects to be called once and succeed
$serviceMock->expects($this->once())
->method('syncRemotelyDeletedObjects')
->with(\Jiminny\Enums\CrmObject::ACCOUNT);
// Call the protected method using reflection
$reflection = new \ReflectionClass($serviceMock);
$method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');
$method->setAccessible(true);
// Should not throw any exceptions
$method->invoke($serviceMock, \Jiminny\Enums\CrmObject::ACCOUNT);
$this->assertTrue(true); // Test completed successfully
}
public function testSyncRemotelyDeletedObjectsWithErrorHandlingFailure(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$prospectPhotoPathService,
])
->onlyMethods(['syncRemotelyDeletedObjects'])
->getMock();
// Mock team
$team = $this->createMock(Team::class);
$team->method('getUuid')->willReturn('team-uuid-456');
$serviceMock->team = $team;
// Mock logger to verify warning is logged
$logger = $this->createMock(\Psr\Log\LoggerInterface::class);
// Use reflection to set the protected logger property
$reflection = new \ReflectionClass($serviceMock);
$loggerProperty = $reflection->getProperty('logger');
$loggerProperty->setAccessible(true);
$loggerProperty->setValue($serviceMock, $logger);
$exception = new \Exception('Sync failed due to API error');
// Expect syncRemotelyDeletedObjects to throw an exception
$serviceMock->expects($this->once())
->method('syncRemotelyDeletedObjects')
->with(\Jiminny\Enums\CrmObject::CONTACT)
->willThrowException($exception);
// Expect warning to be logged with correct message and parameters
$logger->expects($this->once())
->method('warning')
->with(
'[Salesforce] Remotely deleted objects sync failed',
[
'objectType' => 'contact',
'teamId' => 'team-uuid-456',
'reason' => 'Sync failed due to API error',
]
);
// Call the protected method using reflection
$reflection = new \ReflectionClass($serviceMock);
$method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');
$method->setAccessible(true);
// Should not re-throw the exception, just log it
$method->invoke($serviceMock, \Jiminny\Enums\CrmObject::CONTACT);
$this->assertTrue(true); // Test completed successfully
}
public function testSyncRemotelyDeletedObjectsWithErrorHandlingWithLogParams(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$prospectPhotoPathService,
])
->onlyMethods(['syncRemotelyDeletedObjects'])
->getMock();
// Mock team
$team = $this->createMock(Team::class);
$team->method('getUuid')->willReturn('team-uuid-789');
$serviceMock->team = $team;
// Mock logger to verify warning is logged
$logger = $this->createMock(\Psr\Log\LoggerInterface::class);
// Use reflection to set the protected logger property
$loggerReflection = new \ReflectionClass($serviceMock);
$loggerProperty = $loggerReflection->getProperty('logger');
$loggerProperty->setAccessible(true);
$loggerProperty->setValue($serviceMock, $logger);
$exception = new \Exception('Network timeout');
// Expect syncRemotelyDeletedObjects to throw an exception
$serviceMock->expects($this->once())
->method('syncRemotelyDeletedObjects')
->with(\Jiminny\Enums\CrmObject::OPPORTUNITY)
->willThrowException($exception);
// Additional log parameters
$logParams = [
'syncType' => 'full',
'batchSize' => 100,
];
// Expect warning to be logged with merged parameters
$logger->expects($this->once())
->method('warning')
->with(
'[Salesforce] Remotely deleted objects sync failed',
[
'objectType' => 'opportunity',
'teamId' => 'team-uuid-789',
'reason' => 'Network timeout',
'syncType' => 'full',
'batchSize' => 100,
]
);
// Call the protected method using reflection
$reflection = new \ReflectionClass($serviceMock);
$method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');
...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
70625
|
2536
|
5
|
2026-05-22T10:24:24.294929+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779445464294_m1.jpg...
|
PhpStorm
|
faVsco.js – ServiceTest.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20963-fix-import-on-de Project: faVsco.js, menu
JY-20963-fix-import-on-deleted-entity, menu
Start Listening for PHP Debug Connections
ServiceTest
Rerun 'PHPUnit: ServiceTest'
Debug 'ServiceTest'
Stop 'ServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Built-in Preview
Chrome
Firefox
Safari
Sync Changes
Hide This Notification
Code changed:
Hide
4
32
168
1
28
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Tests\Unit\Services\Crm\Salesforce;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Events\Dispatcher;
use Illuminate\Support\Facades\Cache;
use Jiminny\Component\Country\CountriesMap;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Enums\CrmObject;
use Jiminny\Exceptions\InvalidArgumentException;
use Jiminny\Exceptions\NoResultsException;
use Jiminny\Integrations\PlaybookResolver;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Playbook;
use Jiminny\Models\Team;
use Jiminny\Models\TeamSettings;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\ContactRoleRepository;
use Jiminny\Repositories\Crm\FieldDataRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\Salesforce\Client;
use Jiminny\Services\Crm\Salesforce\PayloadBuilder;
use Jiminny\Services\Crm\Salesforce\QueryBuilder;
use Jiminny\Services\Crm\Salesforce\QueryHandler;
use Jiminny\Services\Crm\Salesforce\QueryIterator;
use Jiminny\Services\Crm\Salesforce\QueryResults;
use Jiminny\Services\Crm\Salesforce\Service;
use PHPUnit\Framework\MockObject\MockObject;
use Tests\TestCase;
use Tests\Unit\Traits\TestPrivateMethod;
class ServiceTest extends TestCase
{
use TestPrivateMethod;
public function testFetchAndAssociateRelatedActivity(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$payloadBuilder->method('addCustomLogicFieldsPayload')
->willReturnCallback(function ($activity, $payload) {
return $payload;
});
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods(['fetchRelatedActivity', 'getPlaybook', 'getPlaybookCategory', 'updateRecord'])
->getMock();
$serviceMock->expects($this->once())
->method('fetchRelatedActivity')
->willReturn([
'Id' => 'testId',
'Type' => null,
'OwnerId' => 'testerUser',
'Description' => 'Test description',
]);
$user = $this->createMock(User::class);
$team = $this->createMock(Team::class);
$user->method('getAttribute')->with('team')->willReturn($team);
$playbook = $this->createMock(Playbook::class);
$playbook->method('getActivityField')->willReturn(null);
$playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_EVENT);
$serviceMock->expects($this->once())
->method('getPlaybook')
->with($user)
->willReturn($playbook);
$serviceMock->expects($this->never())
->method('getPlaybookCategory');
$serviceMock->expects($this->never())
->method('updateRecord');
$fieldDataRepository = $this->createMock(FieldDataRepository::class);
$fieldDataRepository->method('getActivityFieldData')->willReturn(collect([]));
app()->instance(FieldDataRepository::class, $fieldDataRepository);
$config = $this->createMock(Configuration::class);
$profilesRelation = $this->getMockBuilder(\Illuminate\Database\Eloquent\Relations\HasMany::class)
->disableOriginalConstructor()
->onlyMethods(['get'])
->addMethods(['where'])
->getMock();
$profilesRelation->method('where')->willReturnSelf();
$profilesRelation->method('get')->willReturn(collect([]));
$config->method('profiles')->willReturn($profilesRelation);
$serviceMock->config = $config;
$serviceMock->profile = null;
$actualStartTime = \Carbon\Carbon::now();
$activity = $this->getMockBuilder(Activity::class)
->disableOriginalConstructor()
->onlyMethods(['update', 'hasProspect'])
->getMock();
$activity->method('update')->willReturn(true);
$activity->method('hasProspect')->willReturn(true);
$activity->type = Activity::TYPE_CONFERENCE;
$activity->provider = Activity::PROVIDER_TWILIO;
$activity->lead_id = 1;
$activity->user_id = 0;
$activity->id_string = 'test-activity-id';
$activity->user = $user;
$activity->actual_start_time = $actualStartTime;
$activity->uuid = 'c53d8320-f556-4cee-a2f8-5f232f454ca4';
app()->bind(PlaybookResolver::class, function () use ($user) {
$playbook = $this->createMock(Playbook::class);
$playbookResolver = $this->createMock(PlaybookResolver::class);
$playbookResolver->expects($this->once())
->method('resolvePlaybookByUser')
->with($user)
->willReturn($playbook);
return $playbookResolver;
});
$data = $serviceMock->fetchAndAssociateRelatedActivity($activity);
$this->assertInstanceOf(Activity::class, $data);
$this->assertEquals(Activity::TYPE_CONFERENCE, $data->getType());
$this->assertEquals($actualStartTime->getTimestamp(), $data->getActualStartTime()->getTimestamp());
}
public function testFetchAndAssociateRelatedActivitySkipsForTaskBasedPlaybook(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods(['fetchRelatedActivity', 'getPlaybook'])
->getMock();
$user = $this->createMock(User::class);
$playbook = $this->createMock(Playbook::class);
$playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);
$playbook->method('getId')->willReturn(123);
$serviceMock->expects($this->once())
->method('getPlaybook')
->with($user)
->willReturn($playbook);
$serviceMock->expects($this->never())
->method('fetchRelatedActivity');
$activity = $this->getMockBuilder(Activity::class)
->disableOriginalConstructor()
->onlyMethods(['hasProspect', 'getUuid'])
->getMock();
$activity->method('hasProspect')->willReturn(true);
$activity->method('getUuid')->willReturn('c53d8320-f556-4cee-a2f8-5f232f454ca4');
$activity->type = Activity::TYPE_CONFERENCE;
$activity->actual_start_time = \Carbon\Carbon::now();
$activity->user = $user;
$result = $serviceMock->fetchAndAssociateRelatedActivity($activity);
$this->assertNull($result);
}
public function testFetchAndAssociateRelatedActivityReturnsNullForNonConference(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$serviceMock = new Service(
client: $client,
payloadBuilder: $payloadBuilder,
eventDispatcher: $eventDispatcher,
countriesMap: $countriesMap,
prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)
);
$activity = $this->getMockBuilder(Activity::class)
->disableOriginalConstructor()
->getMock();
$activity->type = Activity::TYPE_SOFTPHONE;
$result = $serviceMock->fetchAndAssociateRelatedActivity($activity);
$this->assertNull($result);
}
public function testFetchAndAssociateRelatedActivityReturnsNullWhenNoStartTime(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$serviceMock = new Service(
client: $client,
payloadBuilder: $payloadBuilder,
eventDispatcher: $eventDispatcher,
countriesMap: $countriesMap,
prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)
);
$activity = $this->getMockBuilder(Activity::class)
->disableOriginalConstructor()
->getMock();
$activity->type = Activity::TYPE_CONFERENCE;
$activity->actual_start_time = null;
$activity->scheduled_start_time = null;
$result = $serviceMock->fetchAndAssociateRelatedActivity($activity);
$this->assertNull($result);
}
public function testFetchAndAssociateRelatedActivityReturnsNullWhenNoProspect(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods(['getPlaybook'])
->getMock();
$serviceMock->expects($this->never())
->method('getPlaybook');
$activity = $this->getMockBuilder(Activity::class)
->disableOriginalConstructor()
->onlyMethods(['hasProspect', 'getUuid'])
->getMock();
$activity->method('hasProspect')->willReturn(false);
$activity->method('getUuid')->willReturn('c53d8320-f556-4cee-a2f8-5f232f454ca4');
$activity->type = Activity::TYPE_CONFERENCE;
$activity->actual_start_time = \Carbon\Carbon::now();
$result = $serviceMock->fetchAndAssociateRelatedActivity($activity);
$this->assertNull($result);
}
public function testMatchExactlyByEmail(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods([])
->getMock();
$profile = new Profile();
$profile->setAttribute('id', bin2hex(random_bytes(8)));
$serviceMock->profile = $profile;
$team = $this->createMock(Team::class);
$serviceMock->team = $team;
$data = $serviceMock->matchExactlyByEmail(bin2hex(random_bytes(8)) . '[EMAIL]');
$this->assertEquals(null, $data);
}
public function testMatchDomainFromEmail(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$queryIterator = $this->createMock(QueryIterator::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$config = $this->createMock(Configuration::class);
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('search')
->willReturn($queryIterator);
return $handler;
});
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods(['convertCrmData'])
->getMock();
$profile = new Profile();
$profile->account_fields = 'Field1, Field2, Field3';
$serviceMock->profile = $profile;
$serviceMock->expects($this->once())
->method('convertCrmData')
->willReturn(['test']);
$this->app->bind(QueryBuilder::class, function () {
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->expects($this->once())
->method('buildMatchByDomainQuery')
->with('[EMAIL]')
->willReturn('FIND {[EMAIL]} IN ALL FIELDS RETURNING Account(Id)');
return $queryBuilder;
});
$team = $this->createMock(Team::class);
$serviceMock->team = $team;
$serviceMock->config = $config;
$data = $serviceMock->matchByDomain('[EMAIL]');
$this->assertEquals(['test'], $data);
}
public function testBuildTaskSearchFields(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$service = new Service(
client: $client,
payloadBuilder: $payloadBuilder,
eventDispatcher: $eventDispatcher,
countriesMap: $countriesMap,
prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)
);
$fields = $service->buildTaskSearchFields();
$expectedFields = ['Id', 'WhoId', 'WhatId', 'AccountId'];
$this->assertEquals($expectedFields, $fields);
}
public function testMapCrmObjects(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$service = new Service(
client: $client,
payloadBuilder: $payloadBuilder,
eventDispatcher: $eventDispatcher,
countriesMap: $countriesMap,
prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class),
);
$sampleTask = [
'WhoId' => '003sampleWhoId',
'AccountId' => 'sampleAccountId',
'WhatId' => 'sampleWhatId',
];
$activityData = $service->mapCrmObjects($sampleTask);
$expectedActivityData = [
'contact' => '003sampleWhoId',
'account' => 'sampleAccountId',
'opportunity' => 'sampleWhatId',
];
$this->assertEquals($expectedActivityData, $activityData);
}
public function testGetInstalledAppVersion(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$queryIterator = $this->createMock(QueryIterator::class);
$queryIterator->expects($this->any())
->method('current')->willReturn([
'SubscriberPackageVersion' => [
'MajorVersion' => '1',
'MinorVersion' => '0',
'PatchVersion' => '1',
'BuildNumber' => '0',
],
]);
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('metadata')
->willReturn($queryIterator);
return $handler;
});
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods(array_diff(get_class_methods(Service::class), ['getInstalledAppVersion']))
->getMock();
$version = $serviceMock->getInstalledAppVersion();
$this->assertEquals('1010', $version);
}
public function testSyncProfiles(): void
{
$userToSearch = null;
$team = $this->createMock(Team::class);
$config = $this->createMockedConfiguration();
$config->expects($this->once())
->method('getId')
->willReturn(1);
$salesforceUser = [
'Email' => '[EMAIL]',
'UserPreferencesLightningExperiencePreferred' => true,
'CallCenterId' => '123',
'Id' => '456',
'ProfileId' => '789',
];
app()->bind(QueryBuilder::class, function () use ($userToSearch) {
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->expects($this->once())
->method('buildGetUsersQuery')
->with($userToSearch)
->willReturn('SELECT * FROM Users');
return $queryBuilder;
});
$queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('query')
->with('SELECT * FROM Users')
->willReturn($queryIterator);
return $handler;
});
$user = $this->createMock(User::class);
$user->expects($this->once())
->method('getId')
->willReturn(123);
$this->mockTeamRepository($team, $salesforceUser, $user);
$profileRepository = $this->createMock(ProfileRepository::class);
$profileRepository->expects($this->once())
->method('updateOrCreateProfile')
->with(
$user,
[
'crm_configuration_id' => 1,
'crm_provider_id' => '456',
],
[
'user_id' => 123,
'edition' => Profile::EDITION_LIGHTNING,
'has_external_cti' => true,
'crm_profile_id' => '789',
]
)
->willReturn(new Profile());
$this->app->instance(ProfileRepository::class, $profileRepository);
$serviceMock = $this->getServiceMock();
$serviceMock->team = $team;
$serviceMock->config = $config;
$result = $serviceMock->syncProfiles($userToSearch);
$this->assertNull($result);
}
public function testSyncProfilesEmailIsNull(): void
{
$userToSearch = $this->createMock(User::class);
$salesforceUser = [
'Email' => null,
];
app()->bind(QueryBuilder::class, function () {
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->expects($this->once())
->method('buildGetUsersQuery')
->with(null)
->willReturn('SELECT * FROM Users');
return $queryBuilder;
});
$queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->exactly(2))
->method('query')
->willReturn($queryIterator);
return $handler;
});
$team = $this->createMock(Team::class);
$user = $this->createMock(User::class);
$this->mockTeamRepository($team, $salesforceUser, $user, false);
$config = $this->createMock(Configuration::class);
$serviceMock = $this->getServiceMock();
$serviceMock->team = $team;
$serviceMock->config = $config;
$profile = $serviceMock->syncProfiles(null);
$this->assertNull($profile);
}
public function testSyncProfilesUserToSearchMatchesCurrentUser(): void
{
$userToSearch = $this->createMock(User::class);
$userToSearch->expects($this->once())
->method('getId')
->willReturn(123);
$team = $this->createMock(Team::class);
$config = $this->createMock(Configuration::class);
$config->expects($this->once())
->method('getId')
->willReturn(1);
$salesforceUser = [
'Email' => '[EMAIL]',
'UserPreferencesLightningExperiencePreferred' => true,
'CallCenterId' => '123',
'Id' => '456',
'ProfileId' => '789',
];
app()->bind(QueryBuilder::class, function () use ($userToSearch) {
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->expects($this->once())
->method('buildGetUsersQuery')
->with($userToSearch)
->willReturn('SELECT * FROM Users');
return $queryBuilder;
});
$queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('query')
->with('SELECT * FROM Users')
->willReturn($queryIterator);
return $handler;
});
$user = $this->createMock(User::class);
$user->expects($this->exactly(2))
->method('getId')
->willReturn(123);
$this->mockTeamRepository($team, $salesforceUser, $user);
$profileRepository = $this->createMock(ProfileRepository::class);
$profileRepository->expects($this->once())
->method('updateOrCreateProfile')
->with(
$user,
[
'crm_configuration_id' => 1,
'crm_provider_id' => '456',
],
[
'user_id' => 123,
'edition' => Profile::EDITION_LIGHTNING,
'has_external_cti' => true,
'crm_profile_id' => '789',
]
)
->willReturn(new Profile());
$this->app->instance(ProfileRepository::class, $profileRepository);
$serviceMock = $this->getServiceMock();
$serviceMock->team = $team;
$serviceMock->config = $config;
$profile = $serviceMock->syncProfiles($userToSearch);
$this->assertInstanceOf(Profile::class, $profile);
}
public function testSyncProfilesWithCustomValidation(): void
{
$userToSearch = null;
$team = $this->createMock(Team::class);
$config = $this->createMockedConfiguration();
$config->expects($this->atLeastOnce()) // Changed from once() to atLeastOnce()
->method('getId')
->willReturn(1);
$salesforceUser = [
'Email' => '[EMAIL]',
'UserPreferencesLightningExperiencePreferred' => true,
'CallCenterId' => '123',
'Id' => '456',
'ProfileId' => '789',
'CustomField' => 'CustomValue',
];
$customRules = [
['field' => 'CustomField', 'value' => 'CustomValue'],
];
$this->mockQueryBuilderAndHandler($userToSearch, [$salesforceUser]);
$user = $this->createMock(User::class);
$user->expects($this->once())
->method('getId')
->willReturn(123);
$this->mockTeamRepository($team, $salesforceUser, $user, true, $customRules);
$this->mockProfileRepository($user);
$serviceMock = $this->getServiceMock();
$serviceMock->team = $team;
$serviceMock->config = $config;
$profile = $serviceMock->syncProfiles($userToSearch);
$this->assertNull($profile);
}
public function testSyncProfilesWithCustomValidationFailing(): void
{
$userToSearch = null;
$team = $this->createMock(Team::class);
$config = $this->createMockedConfiguration();
$salesforceUser = [
'Email' => '[EMAIL]',
'UserPreferencesLightningExperiencePreferred' => true,
'CallCenterId' => '123',
'Id' => '456',
'ProfileId' => '789',
'CustomField' => 'WrongValue',
];
$customRules = [
['field' => 'CustomField', 'value' => 'CustomValue'],
];
$this->mockQueryBuilderAndHandler($userToSearch, [$salesforceUser]);
$teamRepository = $this->getMockForAbstractClass(TeamRepository::class, [], '', false, true, true, ['findActiveTeamMemberByEmail', 'getTeamSetting']);
$teamSettings = $this->createMock(TeamSettings::class);
$teamSettings->method('getValueType')
->willReturn('array');
$teamSettings->method('getValue')
->willReturn(json_encode($customRules));
$teamRepository->expects($this->once())
->method('getTeamSetting')
->with($team, 'custom_profile_validation')
->willReturn($teamSettings);
app()->bind(TeamRepository::class, function () use ($teamRepository) {
return $teamRepository;
});
$profileRepository = $this->createMock(ProfileRepository::class);
$profileRepository->expects($this->never())
->method('updateOrCreateProfile');
$this->app->instance(ProfileRepository::class, $profileRepository);
$serviceMock = $this->getServiceMock();
$serviceMock->team = $team;
$serviceMock->config = $config;
$result = $serviceMock->syncProfiles($userToSearch);
$this->assertNull($result);
}
public function testGetContactRolesFromCrm(): void
{
$contactRoles = [
[
'Id' => '1',
'ContactId' => 'Contact1',
'OpportunityId' => 'Opportunity1',
'Opportunity' => ['OwnerId' => 'Owner1'],
'IsPrimary' => true,
'Role' => 'Decision Maker',
],
];
$expectedResponse = [
[
'id' => '1',
'contactId' => 'Contact1',
'opportunityId' => 'Opportunity1',
'ownerId' => 'Owner1',
'isPrimary' => true,
'role' => 'Decision Maker',
],
];
$this->bindQueryIterator($contactRoles);
$serviceMock = $this->getServiceMock();
$data = $serviceMock->getContactRolesFromCrm(now()->subDay());
$this->assertEquals($expectedResponse, $data);
}
public function testGetContactRolesFromCrmNoResult(): void
{
$this->bindQueryIterator([]);
$serviceMock = $this->getServiceMock();
$data = $serviceMock->getContactRolesFromCrm(now()->subDay());
$this->assertEquals([], $data);
}
public function testSyncContactRoles(): void
{
$contactRoles = [
[
'id' => '1',
'contactId' => 'Contact1',
'opportunityId' => 'Opportunity1',
'ownerId' => 'Owner1',
'isPrimary' => true,
'role' => 'Decision Maker',
],
];
app()->bind(ContactRoleRepository::class, function () {
$contactRoleRepository = $this->createMock(ContactRoleRepository::class);
$contactRoleRepository->expects($this->once())
->method('saveContactRoles');
return $contactRoleRepository;
});
$serviceMock = $this->getServiceMock([
'getContactRolesFromCrm',
'syncRemotelyDeletedContactRoles',
'syncContact',
'syncOpportunity',
]);
$config = $this->createMock(Configuration::class);
$hasMany = $this->createMock(HasManyExtended::class);
$hasMany->expects($this->exactly(2))
->method('where')
->willReturn($hasMany);
$hasMany->expects($this->exactly(2))
->method('first')
->willReturn(
$this->createMock(Contact::class),
$this->createMock(Opportunity::class)
);
$config->expects($this->once())
->method('contacts')
->willReturn($hasMany);
$config->expects($this->once())
->method('opportunities')
->willReturn($hasMany);
$serviceMock->config = $config;
$serviceMock->expects($this->once())
->method('getContactRolesFromCrm')
->willReturn($contactRoles);
$serviceMock->expects($this->once())
->method('syncRemotelyDeletedContactRoles');
$data = $serviceMock->syncContactRoles(now()->subDay());
$this->assertEquals(1, $data);
}
public function testSyncRemotelyDeletedContactRoles(): void
{
$contactRoles = [
[
'id' => '1',
'crm_provider_id' => '1',
],
];
app()->bind(QueryHandler::class, function () use ($contactRoles) {
$queryResults = new QueryResults($contactRoles, 1, true, null);
$handler = $this->createMock(QueryHandler::class);
$handler->method('queryDeleted')
->willReturn($queryResults);
return $handler;
});
app()->bind(ContactRoleRepository::class, function () {
$contactRoleRepository = $this->createMock(ContactRoleRepository::class);
$contactRoleRepository->expects($this->once())
->method('deleteContactRoles');
return $contactRoleRepository;
});
$serviceMock = $this->getServiceMock();
$serviceMock->team = $this->createMock(Team::class);
$data = $this->invokePrivateMethod('syncRemotelyDeletedContactRoles', $serviceMock, []);
$this->assertTrue($data);
}
private function bindQueryIterator(array $queryResult): void
{
/** @var Client $client */
$client = $this->createMock(Client::class);
$queryIterator = new QueryIterator(
$client,
new QueryResults($queryResult, 1, true, null)
);
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('query')
->willReturn($queryIterator);
return $handler;
});
}
public static function getOpportunitySortOrderDataProvider(): array
{
return [
'all open recently updated' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED, ['LastModifiedDate DESC', true]],
'all open recently created' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED, ['CreatedDate DESC', true]],
'all open oldest created' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED, ['CreatedDate ASC', true]],
'all recently updated' => [Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED, ['LastModifiedDate DESC', false]],
'default' => ['unknown', ['LastModifiedDate DESC', true]],
];
}
private function createMockedConfiguration(): Configuration
{
$config = $this->createMock(Configuration::class);
$profilesRelation = $this->getMockBuilder(\Illuminate\Database\Eloquent\Relations\HasMany::class)
->disableOriginalConstructor()
->onlyMethods(['get'])
->addMethods(['where', 'first'])
->getMock();
$profilesRelation->method('where')->willReturnSelf();
$profilesRelation->method('get')->willReturn(collect([]));
$profilesRelation->method('first')->willReturn(null);
$config->method('profiles')->willReturn($profilesRelation);
return $config;
}
private function getServiceMock(array $onlyMethods = []): MockObject&Service
{
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$this->createMock(Client::class),
$this->createMock(PayloadBuilder::class),
$this->createMock(Dispatcher::class),
$this->createMock(CountriesMap::class),
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods($onlyMethods)
->getMock();
$serviceMock->profile = $this->createMock(Profile::class);
return $serviceMock;
}
private function mockQueryBuilderAndHandler($userToSearch, $salesforceUsers): void
{
app()->bind(QueryBuilder::class, function () use ($userToSearch) {
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->expects($this->once())
->method('buildGetUsersQuery')
->with($userToSearch)
->willReturn('SELECT * FROM Users');
return $queryBuilder;
});
$queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults($salesforceUsers, count($salesforceUsers), true, null));
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('query')
->willReturn($queryIterator);
return $handler;
});
}
private function mockTeamRepository(
Team $team,
array $salesforceUser,
?User $user = null,
bool $userSearch = true,
array $customRules = []
): void {
$teamRepository = $this->getMockForAbstractClass(TeamRepository::class, [], '', false, true, true, ['findActiveTeamMemberByEmail', 'getTeamSetting']);
if ($userSearch) {
$teamRepository->expects($this->once())
->method('findActiveTeamMemberByEmail')
->with($team, $salesforceUser['Email'])
->willReturn($user);
}
$teamSettings = $this->createMock(TeamSettings::class);
$teamSettings->method('getValueType')
->willReturn('array');
$teamSettings->method('getValue')
->willReturn(json_encode($customRules));
$teamRepository->expects($this->once())
->method('getTeamSetting')
->with($team, 'custom_profile_validation')
->willReturn($teamSettings);
app()->bind(TeamRepository::class, function () use ($teamRepository) {
return $teamRepository;
});
}
private function mockProfileRepository(User $user): void
{
$profileRepository = $this->createMock(ProfileRepository::class);
$profileRepository->expects($this->once())
->method('updateOrCreateProfile')
->with(
$user,
[
'crm_configuration_id' => 1,
'crm_provider_id' => '456',
],
[
'user_id' => 123,
'edition' => Profile::EDITION_LIGHTNING,
'has_external_cti' => true,
'crm_profile_id' => '789',
]
)
->willReturn(new Profile());
$this->app->instance(ProfileRepository::class, $profileRepository);
}
public function testBuildEnhancedNoteDecodesHtmlEntities(): void
{
$service = $this->getServiceMock(['createRecord']);
$profile = new Profile();
$profile->setAttribute('crm_provider_id', 'owner-123');
$service->profile = $profile;
$service->expects($this->exactly(2))
->method('createRecord')
->willReturnOnConsecutiveCalls('note-id-123', 'link-id-456');
$bodyWithEntities = 'Welch's current challenges and Facebook's Club';
$result = $this->invokePrivateMethod('buildEnhancedNote', $service, [
'Test Title',
$bodyWithEntities,
'object-id-789',
]);
$this->assertEquals('note-id-123', $result);
}
public function testBuildEnhancedNoteSanitizesWithoutQuotes(): void
{
$service = $this->getServiceMock(['createRecord']);
$profile = new Profile();
$profile->setAttribute('crm_provider_id', 'owner-456');
$service->profile = $profile;
$service->expects($this->exactly(2))
->method('createRecord')
->willReturnCallback(function ($type, $data) {
if ($type === 'ContentNote') {
$decoded = base64_decode($data['Content']);
$this->assertStringContainsString("Welch's", $decoded);
$this->assertStringNotContainsString(''', $decoded);
$this->assertStringNotContainsString('&#039;', $decoded);
$this->assertStringContainsString('<script>', $decoded);
return 'note-id-456';
}
return 'link-id-789';
});
$bodyWithMixedContent = "Welch's and <script>alert('xss')</script>";
$result = $this->invokePrivateMethod('buildEnhancedNote', $service, [
'Test Title',
$bodyWithMixedContent,
'object-id-123',
]);
$this->assertEquals('note-id-456', $result);
}
public function testBuildEnhancedNoteConvertsLineBreaks(): void
{
$service = $this->getServiceMock(['createRecord']);
$profile = new Profile();
$profile->setAttribute('crm_provider_id', 'owner-789');
$service->profile = $profile;
$service->expects($this->exactly(2))
->method('createRecord')
->willReturnCallback(function ($type, $data) {
if ($type === 'ContentNote') {
$decoded = base64_decode($data['Content']);
$this->assertStringContainsString('<br>', $decoded);
$this->assertStringNotContainsString('<br />', $decoded);
return 'note-id-789';
}
return 'link-id-012';
});
$bodyWithLineBreaks = "Line 1\nLine 2\nLine 3";
$result = $this->invokePrivateMethod('buildEnhancedNote', $service, [
'Test Title',
$bodyWithLineBreaks,
'object-id-456',
]);
$this->assertEquals('note-id-789', $result);
}
public function testBuildEnhancedNoteHandlesComplexScenario(): void
{
$service = $this->getServiceMock(['createRecord']);
$profile = new Profile();
$profile->setAttribute('crm_provider_id', 'owner-complex');
$service->profile = $profile;
$service->expects($this->exactly(2))
->method('createRecord')
->willReturnCallback(function ($type, $data) {
if ($type === 'ContentNote') {
$decoded = base64_decode($data['Content']);
$this->assertStringContainsString("Welch's", $decoded);
$this->assertStringContainsString("Facebook's Club", $decoded);
$this->assertStringContainsString("Arctics'", $decoded);
$this->assertStringNotContainsString(''', $decoded);
$this->assertStringNotContainsString('&#039;', $decoded);
$this->assertStringContainsString('<br>', $decoded);
$this->assertStringContainsString('<', $decoded);
$this->assertStringContainsString('>', $decoded);
return 'note-complex';
}
return 'link-complex';
});
$complexBody = "Summary:\n---------\nThe call focused on understanding Welch's current challenges and exploring how Arctics' Revenue Growth Management solutions could support their strategic goals.\n\n• John SMith discussed his role as a category advisor for Google and Facebook's Club, emphasizing the importance of market research and advising on product assortment.\n• Madona introduced Arctics' Virtual Shoppers AI, which simulates consumer <behavior> to optimize pricing and promotional strategies.";
$result = $this->invokePrivateMethod('buildEnhancedNote', $service, [
'Jiminny Transcription Summary',
$complexBody,
'task-id-001',
]);
$this->assertEquals('note-complex', $result);
}
public function testSyncRemotelyDeletedObjectsWithErrorHandlingSuccess(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$prospectPhotoPathService,
])
->onlyMethods(['syncRemotelyDeletedObjects'])
->getMock();
// Mock team
$team = $this->createMock(Team::class);
$team->method('getUuid')->willReturn('team-uuid-123');
$serviceMock->team = $team;
// Expect syncRemotelyDeletedObjects to be called once and succeed
$serviceMock->expects($this->once())
->method('syncRemotelyDeletedObjects')
->with(\Jiminny\Enums\CrmObject::ACCOUNT);
// Call the protected method using reflection
$reflection = new \ReflectionClass($serviceMock);
$method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');
$method->setAccessible(true);
// Should not throw any exceptions
$method->invoke($serviceMock, \Jiminny\Enums\CrmObject::ACCOUNT);
$this->assertTrue(true); // Test completed successfully
}
public function testSyncRemotelyDeletedObjectsWithErrorHandlingFailure(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$prospectPhotoPathService,
])
->onlyMethods(['syncRemotelyDeletedObjects'])
->getMock();
// Mock team
$team = $this->createMock(Team::class);
$team->method('getUuid')->willReturn('team-uuid-456');
$serviceMock->team = $team;
// Mock logger to verify warning is logged
$logger = $this->createMock(\Psr\Log\LoggerInterface::class);
// Use reflection to set the protected logger property
$reflection = new \ReflectionClass($serviceMock);
$loggerProperty = $reflection->getProperty('logger');
$loggerProperty->setAccessible(true);
$loggerProperty->setValue($serviceMock, $logger);
$exception = new \Exception('Sync failed due to API error');
// Expect syncRemotelyDeletedObjects to throw an exception
$serviceMock->expects($this->once())
->method('syncRemotelyDeletedObjects')
->with(\Jiminny\Enums\CrmObject::CONTACT)
->willThrowException($exception);
// Expect warning to be logged with correct message and parameters
$logger->expects($this->once())
->method('warning')
->with(
'[Salesforce] Remotely deleted objects sync failed',
[
'objectType' => 'contact',
'teamId' => 'team-uuid-456',
'reason' => 'Sync failed due to API error',
]
);
// Call the protected method using reflection
$reflection = new \ReflectionClass($serviceMock);
$method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');
$method->setAccessible(true);
// Should not re-throw the exception, just log it
$method->invoke($serviceMock, \Jiminny\Enums\CrmObject::CONTACT);
$this->assertTrue(true); // Test completed successfully
}
public function testSyncRemotelyDeletedObjectsWithErrorHandlingWithLogParams(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$prospectPhotoPathService,
])
->onlyMethods(['syncRemotelyDeletedObjects'])
->getMock();
// Mock team
$team = $this->createMock(Team::class);
$team->method('getUuid')->willReturn('team-uuid-789');
$serviceMock->team = $team;
// Mock logger to verify warning is logged
$logger = $this->createMock(\Psr\Log\LoggerInterface::class);
// Use reflection to set the protected logger property
$loggerReflection = new \ReflectionClass($serviceMock);
$loggerProperty = $loggerReflection->getProperty('logger');
$loggerProperty->setAccessible(true);
$loggerProperty->setValue($serviceMock, $logger);
$exception = new \Exception('Network timeout');
// Expect syncRemotelyDeletedObjects to throw an exception
$serviceMock->expects($this->once())
->method('syncRemotelyDeletedObjects')
->with(\Jiminny\Enums\CrmObject::OPPORTUNITY)
->willThrowException($exception);
// Additional log parameters
$logParams = [
'syncType' => 'full',
'batchSize' => 100,
];
// Expect warning to be logged with merged parameters
$logger->expects($this->once())
->method('warning')
->with(
'[Salesforce] Remotely deleted objects sync failed',
[
'objectType' => 'opportunity',
'teamId' => 'team-uuid-789',
'reason' => 'Network timeout',
'syncType' => 'full',
'batchSize' => 100,
]
);
// Call the protected method using reflection
$reflection = new \ReflectionClass($serviceMock);
$method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');
...
|
[{"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-20963-fix-import-on-deleted-entity, menu","depth":5,"on_screen":true,"help_text":"Git Branch: JY-20963-fix-import-on-deleted-entity","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"ServiceTest","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Rerun 'PHPUnit: ServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'ServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Stop 'ServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Built-in Preview","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":"Chrome","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":"Firefox","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":"Safari","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":"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":"4","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"32","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"168","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"28","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Services\\Crm\\Salesforce;\n\nuse Illuminate\\Database\\Eloquent\\Relations\\HasMany;\nuse Illuminate\\Events\\Dispatcher;\nuse Illuminate\\Support\\Facades\\Cache;\nuse Jiminny\\Component\\Country\\CountriesMap;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Enums\\CrmObject;\nuse Jiminny\\Exceptions\\InvalidArgumentException;\nuse Jiminny\\Exceptions\\NoResultsException;\nuse Jiminny\\Integrations\\PlaybookResolver;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Crm\\Configuration;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\TeamSettings;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\ContactRoleRepository;\nuse Jiminny\\Repositories\\Crm\\FieldDataRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\Salesforce\\Client;\nuse Jiminny\\Services\\Crm\\Salesforce\\PayloadBuilder;\nuse Jiminny\\Services\\Crm\\Salesforce\\QueryBuilder;\nuse Jiminny\\Services\\Crm\\Salesforce\\QueryHandler;\nuse Jiminny\\Services\\Crm\\Salesforce\\QueryIterator;\nuse Jiminny\\Services\\Crm\\Salesforce\\QueryResults;\nuse Jiminny\\Services\\Crm\\Salesforce\\Service;\nuse PHPUnit\\Framework\\MockObject\\MockObject;\nuse Tests\\TestCase;\nuse Tests\\Unit\\Traits\\TestPrivateMethod;\n\nclass ServiceTest extends TestCase\n{\n use TestPrivateMethod;\n\n public function testFetchAndAssociateRelatedActivity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $payloadBuilder->method('addCustomLogicFieldsPayload')\n ->willReturnCallback(function ($activity, $payload) {\n return $payload;\n });\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods(['fetchRelatedActivity', 'getPlaybook', 'getPlaybookCategory', 'updateRecord'])\n ->getMock();\n\n $serviceMock->expects($this->once())\n ->method('fetchRelatedActivity')\n ->willReturn([\n 'Id' => 'testId',\n 'Type' => null,\n 'OwnerId' => 'testerUser',\n 'Description' => 'Test description',\n ]);\n\n $user = $this->createMock(User::class);\n $team = $this->createMock(Team::class);\n $user->method('getAttribute')->with('team')->willReturn($team);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityField')->willReturn(null);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_EVENT);\n\n $serviceMock->expects($this->once())\n ->method('getPlaybook')\n ->with($user)\n ->willReturn($playbook);\n\n $serviceMock->expects($this->never())\n ->method('getPlaybookCategory');\n\n $serviceMock->expects($this->never())\n ->method('updateRecord');\n\n $fieldDataRepository = $this->createMock(FieldDataRepository::class);\n $fieldDataRepository->method('getActivityFieldData')->willReturn(collect([]));\n app()->instance(FieldDataRepository::class, $fieldDataRepository);\n\n $config = $this->createMock(Configuration::class);\n $profilesRelation = $this->getMockBuilder(\\Illuminate\\Database\\Eloquent\\Relations\\HasMany::class)\n ->disableOriginalConstructor()\n ->onlyMethods(['get'])\n ->addMethods(['where'])\n ->getMock();\n $profilesRelation->method('where')->willReturnSelf();\n $profilesRelation->method('get')->willReturn(collect([]));\n $config->method('profiles')->willReturn($profilesRelation);\n\n $serviceMock->config = $config;\n $serviceMock->profile = null;\n\n $actualStartTime = \\Carbon\\Carbon::now();\n\n $activity = $this->getMockBuilder(Activity::class)\n ->disableOriginalConstructor()\n ->onlyMethods(['update', 'hasProspect'])\n ->getMock();\n\n $activity->method('update')->willReturn(true);\n $activity->method('hasProspect')->willReturn(true);\n\n $activity->type = Activity::TYPE_CONFERENCE;\n $activity->provider = Activity::PROVIDER_TWILIO;\n $activity->lead_id = 1;\n $activity->user_id = 0;\n $activity->id_string = 'test-activity-id';\n $activity->user = $user;\n\n $activity->actual_start_time = $actualStartTime;\n $activity->uuid = 'c53d8320-f556-4cee-a2f8-5f232f454ca4';\n\n app()->bind(PlaybookResolver::class, function () use ($user) {\n $playbook = $this->createMock(Playbook::class);\n $playbookResolver = $this->createMock(PlaybookResolver::class);\n $playbookResolver->expects($this->once())\n ->method('resolvePlaybookByUser')\n ->with($user)\n ->willReturn($playbook);\n\n return $playbookResolver;\n });\n\n $data = $serviceMock->fetchAndAssociateRelatedActivity($activity);\n\n $this->assertInstanceOf(Activity::class, $data);\n $this->assertEquals(Activity::TYPE_CONFERENCE, $data->getType());\n $this->assertEquals($actualStartTime->getTimestamp(), $data->getActualStartTime()->getTimestamp());\n }\n\n public function testFetchAndAssociateRelatedActivitySkipsForTaskBasedPlaybook(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods(['fetchRelatedActivity', 'getPlaybook'])\n ->getMock();\n\n $user = $this->createMock(User::class);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n $playbook->method('getId')->willReturn(123);\n\n $serviceMock->expects($this->once())\n ->method('getPlaybook')\n ->with($user)\n ->willReturn($playbook);\n\n $serviceMock->expects($this->never())\n ->method('fetchRelatedActivity');\n\n $activity = $this->getMockBuilder(Activity::class)\n ->disableOriginalConstructor()\n ->onlyMethods(['hasProspect', 'getUuid'])\n ->getMock();\n\n $activity->method('hasProspect')->willReturn(true);\n $activity->method('getUuid')->willReturn('c53d8320-f556-4cee-a2f8-5f232f454ca4');\n $activity->type = Activity::TYPE_CONFERENCE;\n $activity->actual_start_time = \\Carbon\\Carbon::now();\n $activity->user = $user;\n\n $result = $serviceMock->fetchAndAssociateRelatedActivity($activity);\n\n $this->assertNull($result);\n }\n\n public function testFetchAndAssociateRelatedActivityReturnsNullForNonConference(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $serviceMock = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)\n );\n\n $activity = $this->getMockBuilder(Activity::class)\n ->disableOriginalConstructor()\n ->getMock();\n\n $activity->type = Activity::TYPE_SOFTPHONE;\n\n $result = $serviceMock->fetchAndAssociateRelatedActivity($activity);\n\n $this->assertNull($result);\n }\n\n public function testFetchAndAssociateRelatedActivityReturnsNullWhenNoStartTime(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $serviceMock = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)\n );\n\n\n $activity = $this->getMockBuilder(Activity::class)\n ->disableOriginalConstructor()\n ->getMock();\n\n $activity->type = Activity::TYPE_CONFERENCE;\n $activity->actual_start_time = null;\n $activity->scheduled_start_time = null;\n\n $result = $serviceMock->fetchAndAssociateRelatedActivity($activity);\n\n $this->assertNull($result);\n }\n\n public function testFetchAndAssociateRelatedActivityReturnsNullWhenNoProspect(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods(['getPlaybook'])\n ->getMock();\n\n $serviceMock->expects($this->never())\n ->method('getPlaybook');\n\n $activity = $this->getMockBuilder(Activity::class)\n ->disableOriginalConstructor()\n ->onlyMethods(['hasProspect', 'getUuid'])\n ->getMock();\n\n $activity->method('hasProspect')->willReturn(false);\n $activity->method('getUuid')->willReturn('c53d8320-f556-4cee-a2f8-5f232f454ca4');\n $activity->type = Activity::TYPE_CONFERENCE;\n $activity->actual_start_time = \\Carbon\\Carbon::now();\n\n $result = $serviceMock->fetchAndAssociateRelatedActivity($activity);\n\n $this->assertNull($result);\n }\n\n public function testMatchExactlyByEmail(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods([])\n ->getMock();\n\n $profile = new Profile();\n $profile->setAttribute('id', bin2hex(random_bytes(8)));\n $serviceMock->profile = $profile;\n\n $team = $this->createMock(Team::class);\n $serviceMock->team = $team;\n\n $data = $serviceMock->matchExactlyByEmail(bin2hex(random_bytes(8)) . 'test_email@testserver.com');\n\n $this->assertEquals(null, $data);\n }\n\n public function testMatchDomainFromEmail(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $queryIterator = $this->createMock(QueryIterator::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $config = $this->createMock(Configuration::class);\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('search')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods(['convertCrmData'])\n ->getMock();\n\n $profile = new Profile();\n $profile->account_fields = 'Field1, Field2, Field3';\n $serviceMock->profile = $profile;\n\n $serviceMock->expects($this->once())\n ->method('convertCrmData')\n ->willReturn(['test']);\n\n $this->app->bind(QueryBuilder::class, function () {\n $queryBuilder = $this->createMock(QueryBuilder::class);\n $queryBuilder->expects($this->once())\n ->method('buildMatchByDomainQuery')\n ->with('test_email@testserver.com')\n ->willReturn('FIND {test_email@testserver.com} IN ALL FIELDS RETURNING Account(Id)');\n\n return $queryBuilder;\n });\n\n $team = $this->createMock(Team::class);\n\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n\n $data = $serviceMock->matchByDomain('test_email@testserver.com');\n\n $this->assertEquals(['test'], $data);\n }\n\n public function testBuildTaskSearchFields(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)\n );\n\n $fields = $service->buildTaskSearchFields();\n\n $expectedFields = ['Id', 'WhoId', 'WhatId', 'AccountId'];\n\n $this->assertEquals($expectedFields, $fields);\n }\n\n public function testMapCrmObjects(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class),\n );\n\n $sampleTask = [\n 'WhoId' => '003sampleWhoId',\n 'AccountId' => 'sampleAccountId',\n 'WhatId' => 'sampleWhatId',\n ];\n\n $activityData = $service->mapCrmObjects($sampleTask);\n\n $expectedActivityData = [\n 'contact' => '003sampleWhoId',\n 'account' => 'sampleAccountId',\n 'opportunity' => 'sampleWhatId',\n ];\n\n $this->assertEquals($expectedActivityData, $activityData);\n }\n\n public function testGetInstalledAppVersion(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n $queryIterator = $this->createMock(QueryIterator::class);\n $queryIterator->expects($this->any())\n ->method('current')->willReturn([\n 'SubscriberPackageVersion' => [\n 'MajorVersion' => '1',\n 'MinorVersion' => '0',\n 'PatchVersion' => '1',\n 'BuildNumber' => '0',\n ],\n ]);\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('metadata')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods(array_diff(get_class_methods(Service::class), ['getInstalledAppVersion']))\n ->getMock();\n\n $version = $serviceMock->getInstalledAppVersion();\n\n $this->assertEquals('1010', $version);\n }\n\n public function testSyncProfiles(): void\n {\n $userToSearch = null;\n\n $team = $this->createMock(Team::class);\n $config = $this->createMockedConfiguration();\n $config->expects($this->once())\n ->method('getId')\n ->willReturn(1);\n\n $salesforceUser = [\n 'Email' => 'test@example.com',\n 'UserPreferencesLightningExperiencePreferred' => true,\n 'CallCenterId' => '123',\n 'Id' => '456',\n 'ProfileId' => '789',\n ];\n\n app()->bind(QueryBuilder::class, function () use ($userToSearch) {\n $queryBuilder = $this->createMock(QueryBuilder::class);\n $queryBuilder->expects($this->once())\n ->method('buildGetUsersQuery')\n ->with($userToSearch)\n ->willReturn('SELECT * FROM Users');\n\n return $queryBuilder;\n });\n\n $queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('query')\n ->with('SELECT * FROM Users')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n\n $user = $this->createMock(User::class);\n $user->expects($this->once())\n ->method('getId')\n ->willReturn(123);\n\n $this->mockTeamRepository($team, $salesforceUser, $user);\n\n $profileRepository = $this->createMock(ProfileRepository::class);\n $profileRepository->expects($this->once())\n ->method('updateOrCreateProfile')\n ->with(\n $user,\n [\n 'crm_configuration_id' => 1,\n 'crm_provider_id' => '456',\n ],\n [\n 'user_id' => 123,\n 'edition' => Profile::EDITION_LIGHTNING,\n 'has_external_cti' => true,\n 'crm_profile_id' => '789',\n ]\n )\n ->willReturn(new Profile());\n\n $this->app->instance(ProfileRepository::class, $profileRepository);\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n $result = $serviceMock->syncProfiles($userToSearch);\n\n $this->assertNull($result);\n }\n\n public function testSyncProfilesEmailIsNull(): void\n {\n $userToSearch = $this->createMock(User::class);\n\n $salesforceUser = [\n 'Email' => null,\n ];\n\n app()->bind(QueryBuilder::class, function () {\n $queryBuilder = $this->createMock(QueryBuilder::class);\n $queryBuilder->expects($this->once())\n ->method('buildGetUsersQuery')\n ->with(null)\n ->willReturn('SELECT * FROM Users');\n\n return $queryBuilder;\n });\n\n $queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->exactly(2))\n ->method('query')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n\n $team = $this->createMock(Team::class);\n $user = $this->createMock(User::class);\n $this->mockTeamRepository($team, $salesforceUser, $user, false);\n\n $config = $this->createMock(Configuration::class);\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n\n $profile = $serviceMock->syncProfiles(null);\n\n $this->assertNull($profile);\n }\n\n public function testSyncProfilesUserToSearchMatchesCurrentUser(): void\n {\n $userToSearch = $this->createMock(User::class);\n $userToSearch->expects($this->once())\n ->method('getId')\n ->willReturn(123);\n\n $team = $this->createMock(Team::class);\n $config = $this->createMock(Configuration::class);\n $config->expects($this->once())\n ->method('getId')\n ->willReturn(1);\n\n $salesforceUser = [\n 'Email' => 'test@example.com',\n 'UserPreferencesLightningExperiencePreferred' => true,\n 'CallCenterId' => '123',\n 'Id' => '456',\n 'ProfileId' => '789',\n ];\n\n app()->bind(QueryBuilder::class, function () use ($userToSearch) {\n $queryBuilder = $this->createMock(QueryBuilder::class);\n $queryBuilder->expects($this->once())\n ->method('buildGetUsersQuery')\n ->with($userToSearch)\n ->willReturn('SELECT * FROM Users');\n\n return $queryBuilder;\n });\n\n $queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('query')\n ->with('SELECT * FROM Users')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n\n $user = $this->createMock(User::class);\n $user->expects($this->exactly(2))\n ->method('getId')\n ->willReturn(123);\n\n $this->mockTeamRepository($team, $salesforceUser, $user);\n\n $profileRepository = $this->createMock(ProfileRepository::class);\n $profileRepository->expects($this->once())\n ->method('updateOrCreateProfile')\n ->with(\n $user,\n [\n 'crm_configuration_id' => 1,\n 'crm_provider_id' => '456',\n ],\n [\n 'user_id' => 123,\n 'edition' => Profile::EDITION_LIGHTNING,\n 'has_external_cti' => true,\n 'crm_profile_id' => '789',\n ]\n )\n ->willReturn(new Profile());\n\n $this->app->instance(ProfileRepository::class, $profileRepository);\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n $profile = $serviceMock->syncProfiles($userToSearch);\n\n $this->assertInstanceOf(Profile::class, $profile);\n }\n\n public function testSyncProfilesWithCustomValidation(): void\n {\n $userToSearch = null;\n\n $team = $this->createMock(Team::class);\n $config = $this->createMockedConfiguration();\n $config->expects($this->atLeastOnce()) // Changed from once() to atLeastOnce()\n ->method('getId')\n ->willReturn(1);\n\n $salesforceUser = [\n 'Email' => 'test@example.com',\n 'UserPreferencesLightningExperiencePreferred' => true,\n 'CallCenterId' => '123',\n 'Id' => '456',\n 'ProfileId' => '789',\n 'CustomField' => 'CustomValue',\n ];\n\n $customRules = [\n ['field' => 'CustomField', 'value' => 'CustomValue'],\n ];\n\n $this->mockQueryBuilderAndHandler($userToSearch, [$salesforceUser]);\n\n $user = $this->createMock(User::class);\n $user->expects($this->once())\n ->method('getId')\n ->willReturn(123);\n\n $this->mockTeamRepository($team, $salesforceUser, $user, true, $customRules);\n\n $this->mockProfileRepository($user);\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n $profile = $serviceMock->syncProfiles($userToSearch);\n\n $this->assertNull($profile);\n }\n\n public function testSyncProfilesWithCustomValidationFailing(): void\n {\n $userToSearch = null;\n\n $team = $this->createMock(Team::class);\n $config = $this->createMockedConfiguration();\n\n $salesforceUser = [\n 'Email' => 'test@example.com',\n 'UserPreferencesLightningExperiencePreferred' => true,\n 'CallCenterId' => '123',\n 'Id' => '456',\n 'ProfileId' => '789',\n 'CustomField' => 'WrongValue',\n ];\n\n $customRules = [\n ['field' => 'CustomField', 'value' => 'CustomValue'],\n ];\n\n $this->mockQueryBuilderAndHandler($userToSearch, [$salesforceUser]);\n\n $teamRepository = $this->getMockForAbstractClass(TeamRepository::class, [], '', false, true, true, ['findActiveTeamMemberByEmail', 'getTeamSetting']);\n\n $teamSettings = $this->createMock(TeamSettings::class);\n $teamSettings->method('getValueType')\n ->willReturn('array');\n\n $teamSettings->method('getValue')\n ->willReturn(json_encode($customRules));\n\n $teamRepository->expects($this->once())\n ->method('getTeamSetting')\n ->with($team, 'custom_profile_validation')\n ->willReturn($teamSettings);\n\n app()->bind(TeamRepository::class, function () use ($teamRepository) {\n return $teamRepository;\n });\n\n $profileRepository = $this->createMock(ProfileRepository::class);\n $profileRepository->expects($this->never())\n ->method('updateOrCreateProfile');\n\n $this->app->instance(ProfileRepository::class, $profileRepository);\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n $result = $serviceMock->syncProfiles($userToSearch);\n\n $this->assertNull($result);\n }\n\n public function testGetContactRolesFromCrm(): void\n {\n $contactRoles = [\n [\n 'Id' => '1',\n 'ContactId' => 'Contact1',\n 'OpportunityId' => 'Opportunity1',\n 'Opportunity' => ['OwnerId' => 'Owner1'],\n 'IsPrimary' => true,\n 'Role' => 'Decision Maker',\n ],\n ];\n\n $expectedResponse = [\n [\n 'id' => '1',\n 'contactId' => 'Contact1',\n 'opportunityId' => 'Opportunity1',\n 'ownerId' => 'Owner1',\n 'isPrimary' => true,\n 'role' => 'Decision Maker',\n ],\n ];\n\n $this->bindQueryIterator($contactRoles);\n\n $serviceMock = $this->getServiceMock();\n\n $data = $serviceMock->getContactRolesFromCrm(now()->subDay());\n\n $this->assertEquals($expectedResponse, $data);\n }\n\n public function testGetContactRolesFromCrmNoResult(): void\n {\n $this->bindQueryIterator([]);\n\n $serviceMock = $this->getServiceMock();\n\n $data = $serviceMock->getContactRolesFromCrm(now()->subDay());\n\n $this->assertEquals([], $data);\n }\n\n public function testSyncContactRoles(): void\n {\n $contactRoles = [\n [\n 'id' => '1',\n 'contactId' => 'Contact1',\n 'opportunityId' => 'Opportunity1',\n 'ownerId' => 'Owner1',\n 'isPrimary' => true,\n 'role' => 'Decision Maker',\n ],\n ];\n\n app()->bind(ContactRoleRepository::class, function () {\n $contactRoleRepository = $this->createMock(ContactRoleRepository::class);\n $contactRoleRepository->expects($this->once())\n ->method('saveContactRoles');\n\n return $contactRoleRepository;\n });\n\n $serviceMock = $this->getServiceMock([\n 'getContactRolesFromCrm',\n 'syncRemotelyDeletedContactRoles',\n 'syncContact',\n 'syncOpportunity',\n ]);\n\n $config = $this->createMock(Configuration::class);\n $hasMany = $this->createMock(HasManyExtended::class);\n $hasMany->expects($this->exactly(2))\n ->method('where')\n ->willReturn($hasMany);\n\n $hasMany->expects($this->exactly(2))\n ->method('first')\n ->willReturn(\n $this->createMock(Contact::class),\n $this->createMock(Opportunity::class)\n );\n\n $config->expects($this->once())\n ->method('contacts')\n ->willReturn($hasMany);\n $config->expects($this->once())\n ->method('opportunities')\n ->willReturn($hasMany);\n\n $serviceMock->config = $config;\n\n $serviceMock->expects($this->once())\n ->method('getContactRolesFromCrm')\n ->willReturn($contactRoles);\n\n $serviceMock->expects($this->once())\n ->method('syncRemotelyDeletedContactRoles');\n\n $data = $serviceMock->syncContactRoles(now()->subDay());\n\n $this->assertEquals(1, $data);\n }\n\n public function testSyncRemotelyDeletedContactRoles(): void\n {\n $contactRoles = [\n [\n 'id' => '1',\n 'crm_provider_id' => '1',\n ],\n ];\n\n app()->bind(QueryHandler::class, function () use ($contactRoles) {\n $queryResults = new QueryResults($contactRoles, 1, true, null);\n\n $handler = $this->createMock(QueryHandler::class);\n $handler->method('queryDeleted')\n ->willReturn($queryResults);\n\n return $handler;\n });\n\n app()->bind(ContactRoleRepository::class, function () {\n $contactRoleRepository = $this->createMock(ContactRoleRepository::class);\n $contactRoleRepository->expects($this->once())\n ->method('deleteContactRoles');\n\n return $contactRoleRepository;\n });\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $this->createMock(Team::class);\n\n $data = $this->invokePrivateMethod('syncRemotelyDeletedContactRoles', $serviceMock, []);\n\n $this->assertTrue($data);\n }\n\n private function bindQueryIterator(array $queryResult): void\n {\n /** @var Client $client */\n $client = $this->createMock(Client::class);\n $queryIterator = new QueryIterator(\n $client,\n new QueryResults($queryResult, 1, true, null)\n );\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('query')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n }\n\n public static function getOpportunitySortOrderDataProvider(): array\n {\n return [\n 'all open recently updated' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED, ['LastModifiedDate DESC', true]],\n 'all open recently created' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED, ['CreatedDate DESC', true]],\n 'all open oldest created' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED, ['CreatedDate ASC', true]],\n 'all recently updated' => [Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED, ['LastModifiedDate DESC', false]],\n 'default' => ['unknown', ['LastModifiedDate DESC', true]],\n ];\n }\n\n private function createMockedConfiguration(): Configuration\n {\n $config = $this->createMock(Configuration::class);\n $profilesRelation = $this->getMockBuilder(\\Illuminate\\Database\\Eloquent\\Relations\\HasMany::class)\n ->disableOriginalConstructor()\n ->onlyMethods(['get'])\n ->addMethods(['where', 'first'])\n ->getMock();\n $profilesRelation->method('where')->willReturnSelf();\n $profilesRelation->method('get')->willReturn(collect([]));\n $profilesRelation->method('first')->willReturn(null);\n $config->method('profiles')->willReturn($profilesRelation);\n\n return $config;\n }\n\n private function getServiceMock(array $onlyMethods = []): MockObject&Service\n {\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $this->createMock(Client::class),\n $this->createMock(PayloadBuilder::class),\n $this->createMock(Dispatcher::class),\n $this->createMock(CountriesMap::class),\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods($onlyMethods)\n ->getMock();\n\n $serviceMock->profile = $this->createMock(Profile::class);\n\n return $serviceMock;\n }\n\n private function mockQueryBuilderAndHandler($userToSearch, $salesforceUsers): void\n {\n app()->bind(QueryBuilder::class, function () use ($userToSearch) {\n $queryBuilder = $this->createMock(QueryBuilder::class);\n $queryBuilder->expects($this->once())\n ->method('buildGetUsersQuery')\n ->with($userToSearch)\n ->willReturn('SELECT * FROM Users');\n\n return $queryBuilder;\n });\n\n $queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults($salesforceUsers, count($salesforceUsers), true, null));\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('query')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n }\n\n private function mockTeamRepository(\n Team $team,\n array $salesforceUser,\n ?User $user = null,\n bool $userSearch = true,\n array $customRules = []\n ): void {\n $teamRepository = $this->getMockForAbstractClass(TeamRepository::class, [], '', false, true, true, ['findActiveTeamMemberByEmail', 'getTeamSetting']);\n\n if ($userSearch) {\n $teamRepository->expects($this->once())\n ->method('findActiveTeamMemberByEmail')\n ->with($team, $salesforceUser['Email'])\n ->willReturn($user);\n }\n\n $teamSettings = $this->createMock(TeamSettings::class);\n $teamSettings->method('getValueType')\n ->willReturn('array');\n\n $teamSettings->method('getValue')\n ->willReturn(json_encode($customRules));\n\n $teamRepository->expects($this->once())\n ->method('getTeamSetting')\n ->with($team, 'custom_profile_validation')\n ->willReturn($teamSettings);\n\n app()->bind(TeamRepository::class, function () use ($teamRepository) {\n return $teamRepository;\n });\n }\n\n private function mockProfileRepository(User $user): void\n {\n $profileRepository = $this->createMock(ProfileRepository::class);\n $profileRepository->expects($this->once())\n ->method('updateOrCreateProfile')\n ->with(\n $user,\n [\n 'crm_configuration_id' => 1,\n 'crm_provider_id' => '456',\n ],\n [\n 'user_id' => 123,\n 'edition' => Profile::EDITION_LIGHTNING,\n 'has_external_cti' => true,\n 'crm_profile_id' => '789',\n ]\n )\n ->willReturn(new Profile());\n\n $this->app->instance(ProfileRepository::class, $profileRepository);\n }\n\n public function testBuildEnhancedNoteDecodesHtmlEntities(): void\n {\n $service = $this->getServiceMock(['createRecord']);\n\n $profile = new Profile();\n $profile->setAttribute('crm_provider_id', 'owner-123');\n $service->profile = $profile;\n\n $service->expects($this->exactly(2))\n ->method('createRecord')\n ->willReturnOnConsecutiveCalls('note-id-123', 'link-id-456');\n\n $bodyWithEntities = 'Welch's current challenges and Facebook's Club';\n\n $result = $this->invokePrivateMethod('buildEnhancedNote', $service, [\n 'Test Title',\n $bodyWithEntities,\n 'object-id-789',\n ]);\n\n $this->assertEquals('note-id-123', $result);\n }\n\n public function testBuildEnhancedNoteSanitizesWithoutQuotes(): void\n {\n $service = $this->getServiceMock(['createRecord']);\n\n $profile = new Profile();\n $profile->setAttribute('crm_provider_id', 'owner-456');\n $service->profile = $profile;\n\n $service->expects($this->exactly(2))\n ->method('createRecord')\n ->willReturnCallback(function ($type, $data) {\n if ($type === 'ContentNote') {\n $decoded = base64_decode($data['Content']);\n $this->assertStringContainsString(\"Welch's\", $decoded);\n $this->assertStringNotContainsString(''', $decoded);\n $this->assertStringNotContainsString('&#039;', $decoded);\n $this->assertStringContainsString('<script>', $decoded);\n\n return 'note-id-456';\n }\n\n return 'link-id-789';\n });\n\n $bodyWithMixedContent = \"Welch's and <script>alert('xss')</script>\";\n\n $result = $this->invokePrivateMethod('buildEnhancedNote', $service, [\n 'Test Title',\n $bodyWithMixedContent,\n 'object-id-123',\n ]);\n\n $this->assertEquals('note-id-456', $result);\n }\n\n public function testBuildEnhancedNoteConvertsLineBreaks(): void\n {\n $service = $this->getServiceMock(['createRecord']);\n\n $profile = new Profile();\n $profile->setAttribute('crm_provider_id', 'owner-789');\n $service->profile = $profile;\n\n $service->expects($this->exactly(2))\n ->method('createRecord')\n ->willReturnCallback(function ($type, $data) {\n if ($type === 'ContentNote') {\n $decoded = base64_decode($data['Content']);\n $this->assertStringContainsString('<br>', $decoded);\n $this->assertStringNotContainsString('<br />', $decoded);\n\n return 'note-id-789';\n }\n\n return 'link-id-012';\n });\n\n $bodyWithLineBreaks = \"Line 1\\nLine 2\\nLine 3\";\n\n $result = $this->invokePrivateMethod('buildEnhancedNote', $service, [\n 'Test Title',\n $bodyWithLineBreaks,\n 'object-id-456',\n ]);\n\n $this->assertEquals('note-id-789', $result);\n }\n\n public function testBuildEnhancedNoteHandlesComplexScenario(): void\n {\n $service = $this->getServiceMock(['createRecord']);\n\n $profile = new Profile();\n $profile->setAttribute('crm_provider_id', 'owner-complex');\n $service->profile = $profile;\n\n $service->expects($this->exactly(2))\n ->method('createRecord')\n ->willReturnCallback(function ($type, $data) {\n if ($type === 'ContentNote') {\n $decoded = base64_decode($data['Content']);\n\n $this->assertStringContainsString(\"Welch's\", $decoded);\n $this->assertStringContainsString(\"Facebook's Club\", $decoded);\n $this->assertStringContainsString(\"Arctics'\", $decoded);\n $this->assertStringNotContainsString(''', $decoded);\n $this->assertStringNotContainsString('&#039;', $decoded);\n $this->assertStringContainsString('<br>', $decoded);\n $this->assertStringContainsString('<', $decoded);\n $this->assertStringContainsString('>', $decoded);\n\n return 'note-complex';\n }\n\n return 'link-complex';\n });\n\n $complexBody = \"Summary:\\n---------\\nThe call focused on understanding Welch's current challenges and exploring how Arctics' Revenue Growth Management solutions could support their strategic goals.\\n\\n• John SMith discussed his role as a category advisor for Google and Facebook's Club, emphasizing the importance of market research and advising on product assortment.\\n• Madona introduced Arctics' Virtual Shoppers AI, which simulates consumer <behavior> to optimize pricing and promotional strategies.\";\n\n $result = $this->invokePrivateMethod('buildEnhancedNote', $service, [\n 'Jiminny Transcription Summary',\n $complexBody,\n 'task-id-001',\n ]);\n\n $this->assertEquals('note-complex', $result);\n }\n\n public function testSyncRemotelyDeletedObjectsWithErrorHandlingSuccess(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['syncRemotelyDeletedObjects'])\n ->getMock();\n\n // Mock team\n $team = $this->createMock(Team::class);\n $team->method('getUuid')->willReturn('team-uuid-123');\n $serviceMock->team = $team;\n\n // Expect syncRemotelyDeletedObjects to be called once and succeed\n $serviceMock->expects($this->once())\n ->method('syncRemotelyDeletedObjects')\n ->with(\\Jiminny\\Enums\\CrmObject::ACCOUNT);\n\n // Call the protected method using reflection\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');\n $method->setAccessible(true);\n\n // Should not throw any exceptions\n $method->invoke($serviceMock, \\Jiminny\\Enums\\CrmObject::ACCOUNT);\n\n $this->assertTrue(true); // Test completed successfully\n }\n\n public function testSyncRemotelyDeletedObjectsWithErrorHandlingFailure(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['syncRemotelyDeletedObjects'])\n ->getMock();\n\n // Mock team\n $team = $this->createMock(Team::class);\n $team->method('getUuid')->willReturn('team-uuid-456');\n $serviceMock->team = $team;\n\n // Mock logger to verify warning is logged\n $logger = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n\n // Use reflection to set the protected logger property\n $reflection = new \\ReflectionClass($serviceMock);\n $loggerProperty = $reflection->getProperty('logger');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($serviceMock, $logger);\n\n $exception = new \\Exception('Sync failed due to API error');\n\n // Expect syncRemotelyDeletedObjects to throw an exception\n $serviceMock->expects($this->once())\n ->method('syncRemotelyDeletedObjects')\n ->with(\\Jiminny\\Enums\\CrmObject::CONTACT)\n ->willThrowException($exception);\n\n // Expect warning to be logged with correct message and parameters\n $logger->expects($this->once())\n ->method('warning')\n ->with(\n '[Salesforce] Remotely deleted objects sync failed',\n [\n 'objectType' => 'contact',\n 'teamId' => 'team-uuid-456',\n 'reason' => 'Sync failed due to API error',\n ]\n );\n\n // Call the protected method using reflection\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');\n $method->setAccessible(true);\n\n // Should not re-throw the exception, just log it\n $method->invoke($serviceMock, \\Jiminny\\Enums\\CrmObject::CONTACT);\n\n $this->assertTrue(true); // Test completed successfully\n }\n\n public function testSyncRemotelyDeletedObjectsWithErrorHandlingWithLogParams(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['syncRemotelyDeletedObjects'])\n ->getMock();\n\n // Mock team\n $team = $this->createMock(Team::class);\n $team->method('getUuid')->willReturn('team-uuid-789');\n $serviceMock->team = $team;\n\n // Mock logger to verify warning is logged\n $logger = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n\n // Use reflection to set the protected logger property\n $loggerReflection = new \\ReflectionClass($serviceMock);\n $loggerProperty = $loggerReflection->getProperty('logger');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($serviceMock, $logger);\n\n $exception = new \\Exception('Network timeout');\n\n // Expect syncRemotelyDeletedObjects to throw an exception\n $serviceMock->expects($this->once())\n ->method('syncRemotelyDeletedObjects')\n ->with(\\Jiminny\\Enums\\CrmObject::OPPORTUNITY)\n ->willThrowException($exception);\n\n // Additional log parameters\n $logParams = [\n 'syncType' => 'full',\n 'batchSize' => 100,\n ];\n\n // Expect warning to be logged with merged parameters\n $logger->expects($this->once())\n ->method('warning')\n ->with(\n '[Salesforce] Remotely deleted objects sync failed',\n [\n 'objectType' => 'opportunity',\n 'teamId' => 'team-uuid-789',\n 'reason' => 'Network timeout',\n 'syncType' => 'full',\n 'batchSize' => 100,\n ]\n );\n\n // Call the protected method using reflection\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');\n $method->setAccessible(true);\n\n // Should not re-throw the exception, just log it\n $method->invoke($serviceMock, \\Jiminny\\Enums\\CrmObject::OPPORTUNITY, $logParams);\n\n $this->assertTrue(true); // Test completed successfully\n }\n\n /**\n * @dataProvider crmObjectProvider\n */\n public function testSyncRemotelyDeletedObjectsWithErrorHandlingDifferentCrmObjects(\\Jiminny\\Enums\\CrmObject $crmObject): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['syncRemotelyDeletedObjects'])\n ->getMock();\n\n // Mock team\n $team = $this->createMock(Team::class);\n $team->method('getUuid')->willReturn('team-uuid-test');\n $serviceMock->team = $team;\n\n // Mock logger to verify warning is logged\n $logger = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n\n // Use reflection to set the protected logger property\n $loggerReflectionClass = new \\ReflectionClass($serviceMock);\n $loggerProperty = $loggerReflectionClass->getProperty('logger');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($serviceMock, $logger);\n\n $exception = new \\Exception('Test error');\n\n // Expect syncRemotelyDeletedObjects to throw an exception\n $serviceMock->expects($this->once())\n ->method('syncRemotelyDeletedObjects')\n ->with($crmObject)\n ->willThrowException($exception);\n\n // Expect warning to be logged with correct entity type\n $logger->expects($this->once())\n ->method('warning')\n ->with(\n '[Salesforce] Remotely deleted objects sync failed',\n [\n 'objectType' => $crmObject->value,\n 'teamId' => 'team-uuid-test',\n 'reason' => 'Test error',\n ]\n );\n\n // Call the protected method using reflection\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');\n $method->setAccessible(true);\n\n $method->invoke($serviceMock, $crmObject);\n\n $this->assertTrue(true); // Test completed successfully\n }\n\n public function testHandleObjectDeletionWithDeletedEntity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['deleteCrmObject'])\n ->getMock();\n\n $entity = $this->createMock(\\Jiminny\\Models\\Account::class);\n $crmData = ['IsDeleted' => true];\n\n $serviceMock->expects($this->once())\n ->method('deleteCrmObject')\n ->with($entity);\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('handleObjectDeletion');\n $method->setAccessible(true);\n\n $method->invoke($serviceMock, $entity, $crmData);\n }\n\n public function testHandleObjectDeletionWithNonDeletedEntity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['deleteCrmObject'])\n ->getMock();\n\n $entity = $this->createMock(\\Jiminny\\Models\\Contact::class);\n $crmData = ['IsDeleted' => false];\n\n $serviceMock->expects($this->never())\n ->method('deleteCrmObject');\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('handleObjectDeletion');\n $method->setAccessible(true);\n\n $method->invoke($serviceMock, $entity, $crmData);\n }\n\n public function testDeleteCrmObjectWithValidEntity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['dispatchDeleteCrmObjectJob'])\n ->getMock();\n\n $entity = $this->createMock(\\Jiminny\\Models\\Lead::class);\n $entity->expects($this->once())->method('delete');\n\n $serviceMock->expects($this->once())\n ->method('dispatchDeleteCrmObjectJob')\n ->with($entity);\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('deleteCrmObject');\n $method->setAccessible(true);\n\n $method->invoke($serviceMock, $entity);\n }\n\n public function testDeleteCrmObjectWithNullEntity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['dispatchDeleteCrmObjectJob'])\n ->getMock();\n\n $serviceMock->expects($this->never())\n ->method('dispatchDeleteCrmObjectJob');\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('deleteCrmObject');\n $method->setAccessible(true);\n\n $method->invoke($serviceMock, null);\n }\n\n public function testDispatchDeleteCrmObjectJobWithNullEntity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($service);\n $method = $reflection->getMethod('dispatchDeleteCrmObjectJob');\n $method->setAccessible(true);\n\n // Should return early without dispatching - no exception expected\n $method->invoke($service, null);\n\n $this->assertTrue(true); // Test completed successfully\n }\n\n public function testDispatchDeleteCrmObjectJobWithUnsupportedEntity(): void\n {\n $this->expectException(\\TypeError::class);\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n $unsupportedEntity = $this->createMock(\\stdClass::class);\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($service);\n $method = $reflection->getMethod('dispatchDeleteCrmObjectJob');\n\n // This will throw TypeError due to union type constraint\n $method->invoke($service, $unsupportedEntity);\n }\n\n public function testHandleEntityDeletionByProviderIdMethodExists(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n // Test that the method exists and is accessible via reflection\n $reflection = new \\ReflectionClass($service);\n $method = $reflection->getMethod('handleEntityDeletionByProviderId');\n $method->setAccessible(true);\n\n // Verify method exists and has correct parameters\n $this->assertTrue($method->isProtected());\n $this->assertEquals(2, $method->getNumberOfParameters());\n\n $parameters = $method->getParameters();\n $this->assertEquals('targetEntity', $parameters[0]->getName());\n $this->assertEquals('crmData', $parameters[1]->getName());\n }\n\n public function testSyncRemotelyDeletedObjectsWithNoResults(): void\n {\n // Create a real service instance to avoid mock property issues\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n // Mock queryHandler to throw NoResultsException\n $queryHandler = $this->createMock(QueryHandler::class);\n $queryHandler->expects($this->once())\n ->method('queryDeleted')\n ->with('Opportunity')\n ->willThrowException(new NoResultsException('No results'));\n\n // Set the queryHandler using reflection on the real service\n $reflection = new \\ReflectionClass($service);\n $queryHandlerProperty = $reflection->getProperty('queryHandler');\n $queryHandlerProperty->setAccessible(true);\n $queryHandlerProperty->setValue($service, $queryHandler);\n\n $result = self::invokePrivateMethod('syncRemotelyDeletedObjects', $service, [CrmObject::OPPORTUNITY]);\n\n $this->assertFalse($result);\n }\n\n public function testSyncRemotelyDeletedObjectsWithEmptyResults(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n // Mock queryHandler to return empty results\n $queryResult = $this->createMock(QueryResults::class);\n $queryResult->method('getResults')->willReturn([]);\n\n $queryHandler = $this->createMock(QueryHandler::class);\n $queryHandler->expects($this->once())\n ->method('queryDeleted')\n ->with('Opportunity')\n ->willReturn($queryResult);\n\n // Set the queryHandler using reflection on the real service\n $reflection = new \\ReflectionClass($service);\n $queryHandlerProperty = $reflection->getProperty('queryHandler');\n $queryHandlerProperty->setAccessible(true);\n $queryHandlerProperty->setValue($service, $queryHandler);\n\n $result = self::invokePrivateMethod('syncRemotelyDeletedObjects', $service, [CrmObject::OPPORTUNITY]);\n\n $this->assertFalse($result);\n }\n\n public function testSyncRemotelyDeletedObjectsWithUnsupportedCrmObject(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n // Mock queryHandler to return some deleted objects so we reach the match statement\n $deletedObjects = [\n ['id' => 'task1'],\n ['id' => 'task2'],\n ];\n $queryResult = $this->createMock(QueryResults::class);\n $queryResult->method('getResults')->willReturn($deletedObjects);\n\n $queryHandler = $this->createMock(QueryHandler::class);\n $queryHandler->expects($this->once())\n ->method('queryDeleted')\n ->with('Task') // ucfirst('task') = 'Task'\n ->willReturn($queryResult);\n\n self::setPrivateProperty($service, 'queryHandler', $queryHandler);\n\n $this->expectException(InvalidArgumentException::class);\n $this->expectExceptionMessage('Unsupported CrmObject: task');\n\n self::invokePrivateMethod('syncRemotelyDeletedObjects', $service, [CrmObject::TASK]);\n }\n\n public static function crmObjectProvider(): array\n {\n return [\n 'Account' => [CrmObject::ACCOUNT],\n 'Contact' => [CrmObject::CONTACT],\n 'Lead' => [CrmObject::LEAD],\n 'Opportunity' => [CrmObject::OPPORTUNITY],\n ];\n }\n\n public function testVerifyTaskExistsReturnsTrue(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-123', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-123');\n $activity->method('getId')->willReturn(456);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-123', ['Id', 'IsDeleted'])\n ->willReturn(['Id' => 'task-123', 'IsDeleted' => false]);\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertTrue($result);\n }\n\n public function testVerifyTaskExistsReturnsTrueForEvent(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:event-123', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('event-123');\n $activity->method('getId')->willReturn(456);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_EVENT);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Event', 'event-123', ['Id', 'IsDeleted'])\n ->willReturn(['Id' => 'event-123', 'IsDeleted' => false]);\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertTrue($result);\n }\n\n public function testVerifyTaskExistsReturnsFalseWhenDeleted(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-456', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-456');\n $activity->method('getId')->willReturn(789);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-456', ['Id', 'IsDeleted'])\n ->willReturn(['Id' => 'task-456', 'IsDeleted' => true]);\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertFalse($result);\n }\n\n public function testVerifyTaskExistsReturnsFalseWhenNotFound(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-999', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-999');\n $activity->method('getId')->willReturn(999);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-999', ['Id', 'IsDeleted'])\n ->willThrowException(new \\Jiminny\\Exceptions\\HttpNotFoundException('Task not found'));\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertFalse($result);\n }\n\n public function testVerifyTaskExistsReturnsFalseWhenNoPlaybook(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-no-playbook', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-no-playbook');\n $activity->method('getId')->willReturn(111);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn(null);\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertFalse($result);\n }\n\n public function testVerifyTaskExistsThrowsExceptionForTransientErrors(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-error', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-error');\n $activity->method('getId')->willReturn(888);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-error', ['Id', 'IsDeleted'])\n ->willThrowException(new \\RuntimeException('Network timeout'));\n\n $this->expectException(\\RuntimeException::class);\n $this->expectExceptionMessage('Network timeout');\n\n $service->verifyTaskExists($activity);\n }\n\n public function testVerifyTaskExistsCachesResults(): void\n {\n $cachedValue = null;\n Cache::shouldReceive('remember')\n ->twice()\n ->with('crm_task_exists:123:task-cached', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(function ($key, $ttl, $callback) use (&$cachedValue) {\n if ($cachedValue === null) {\n $cachedValue = $callback();\n }\n\n return $cachedValue;\n });\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-cached');\n $activity->method('getId')->willReturn(555);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-cached', ['Id', 'IsDeleted'])\n ->willReturn(['Id' => 'task-cached', 'IsDeleted' => false]);\n\n $result1 = $service->verifyTaskExists($activity);\n $result2 = $service->verifyTaskExists($activity);\n\n $this->assertTrue($result1);\n $this->assertTrue($result2);\n }\n\n public function testVerifyTaskExistsReturnsFalseForHttpBadRequestException(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-400', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-400');\n $activity->method('getId')->willReturn(400);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-400', ['Id', 'IsDeleted'])\n ->willThrowException(new \\Jiminny\\Exceptions\\HttpBadRequestException('Bad request'));\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertFalse($result);\n }\n\n public function testImportOpportunitySkipsWhenNoProfileAndNoAccount(): void\n {\n $crmData = [\n 'Id' => 'SF-NO-USER-1',\n 'Name' => 'Test Opportunity',\n 'OwnerId' => 'owner-no-profile',\n // No AccountId\n ];\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(\\Illuminate\\Events\\Dispatcher::class); // ← ADD THIS\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n\n $service = new Service(\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService\n );\n\n $config = $this->createMock(Configuration::class);\n\n // Mock profiles relation returning null (no profile found)\n $profilesRelation = \\Mockery::mock(\\Illuminate\\Database\\Eloquent\\Relations\\HasMany::class);\n $profilesRelation->shouldReceive('where')->with('crm_provider_id', 'owner-no-profile')->andReturnSelf();\n $profilesRelation->shouldReceive('first')->andReturn(null);\n\n $config->expects($this->once())\n ->method('profiles')\n ->willReturn($profilesRelation);\n\n $team = $this->createMock(Team::class);\n $team->method('getId')->willReturn(1);\n\n $logger = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n $logger->expects($this->once())\n ->method('error')\n ->with(\n '[Salesforce] | Skip import, no user_id found',\n ['id' => 'SF-NO-USER-1']\n );\n\n $reflection = new \\ReflectionClass($service);\n\n $configProperty = $reflection->getProperty('config');\n $configProperty->setAccessible(true);\n $configProperty->setValue($service, $config);\n\n $teamProperty = $reflection->getProperty('team');\n $teamProperty->setAccessible(true);\n $teamProperty->setValue($service, $team);\n\n $loggerProperty = $reflection->getProperty('logger');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($service, $logger);\n\n // Initialize profile property to avoid \"must not be accessed before initialization\" error\n $profileProperty = $reflection->getProperty('profile');\n $profileProperty->setAccessible(true);\n $profileProperty->setValue($service, null);\n\n $result = self::invokePrivateMethod('importOpportunity', $service, [$crmData]);\n\n $this->assertNull($result);\n }\n\n public function testImportOpportunitySkipsWhenProfileNotFound(): void\n {\n $crmData = [\n 'Id' => 'SF-NO-USER-2',\n 'Name' => 'Test Opportunity',\n 'OwnerId' => 'owner-not-found',\n // No AccountId - avoid complex account processing\n ];\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $eventDispatcher = $this->createMock(\\Illuminate\\Events\\Dispatcher::class); // ← ADD THIS\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n\n\n $service = new Service(\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService\n );\n\n $config = $this->createMock(Configuration::class);\n\n // Mock profiles relation returning null (no profile found)\n $profilesRelation = \\Mockery::mock(\\Illuminate\\Database\\Eloquent\\Relations\\HasMany::class);\n $profilesRelation->shouldReceive('where')->with('crm_provider_id', 'owner-not-found')->andReturnSelf();\n $profilesRelation->shouldReceive('first')->andReturn(null);\n\n $config->expects($this->once())\n ->method('profiles')\n ->willReturn($profilesRelation);\n\n $team = $this->createMock(Team::class);\n $team->method('getId')->willReturn(1);\n\n $logger = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n $logger->expects($this->once())\n ->method('error')\n ->with(\n '[Salesforce] | Skip import, no user_id found',\n ['id' => 'SF-NO-USER-2']\n );\n\n $reflection = new \\ReflectionClass($service);\n\n $configProperty = $reflection->getProperty('config');\n $configProperty->setAccessible(true);\n $configProperty->setValue($service, $config);\n\n $teamProperty = $reflection->getProperty('team');\n $teamProperty->setAccessible(true);\n $teamProperty->setValue($service, $team);\n\n $loggerProperty = $reflection->getProperty('logger');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($service, $logger);\n\n // Initialize profile property\n $profileProperty = $reflection->getProperty('profile');\n $profileProperty->setAccessible(true);\n $profileProperty->setValue($service, null);\n\n $result = self::invokePrivateMethod('importOpportunity', $service, [$crmData]);\n\n $this->assertNull($result);\n }\n}\n\nclass HasManyExtended extends HasMany\n{\n public function where()\n {\n }\n\n public function first()\n {\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Services\\Crm\\Salesforce;\n\nuse Illuminate\\Database\\Eloquent\\Relations\\HasMany;\nuse Illuminate\\Events\\Dispatcher;\nuse Illuminate\\Support\\Facades\\Cache;\nuse Jiminny\\Component\\Country\\CountriesMap;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Enums\\CrmObject;\nuse Jiminny\\Exceptions\\InvalidArgumentException;\nuse Jiminny\\Exceptions\\NoResultsException;\nuse Jiminny\\Integrations\\PlaybookResolver;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Crm\\Configuration;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\TeamSettings;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\ContactRoleRepository;\nuse Jiminny\\Repositories\\Crm\\FieldDataRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\Salesforce\\Client;\nuse Jiminny\\Services\\Crm\\Salesforce\\PayloadBuilder;\nuse Jiminny\\Services\\Crm\\Salesforce\\QueryBuilder;\nuse Jiminny\\Services\\Crm\\Salesforce\\QueryHandler;\nuse Jiminny\\Services\\Crm\\Salesforce\\QueryIterator;\nuse Jiminny\\Services\\Crm\\Salesforce\\QueryResults;\nuse Jiminny\\Services\\Crm\\Salesforce\\Service;\nuse PHPUnit\\Framework\\MockObject\\MockObject;\nuse Tests\\TestCase;\nuse Tests\\Unit\\Traits\\TestPrivateMethod;\n\nclass ServiceTest extends TestCase\n{\n use TestPrivateMethod;\n\n public function testFetchAndAssociateRelatedActivity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $payloadBuilder->method('addCustomLogicFieldsPayload')\n ->willReturnCallback(function ($activity, $payload) {\n return $payload;\n });\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods(['fetchRelatedActivity', 'getPlaybook', 'getPlaybookCategory', 'updateRecord'])\n ->getMock();\n\n $serviceMock->expects($this->once())\n ->method('fetchRelatedActivity')\n ->willReturn([\n 'Id' => 'testId',\n 'Type' => null,\n 'OwnerId' => 'testerUser',\n 'Description' => 'Test description',\n ]);\n\n $user = $this->createMock(User::class);\n $team = $this->createMock(Team::class);\n $user->method('getAttribute')->with('team')->willReturn($team);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityField')->willReturn(null);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_EVENT);\n\n $serviceMock->expects($this->once())\n ->method('getPlaybook')\n ->with($user)\n ->willReturn($playbook);\n\n $serviceMock->expects($this->never())\n ->method('getPlaybookCategory');\n\n $serviceMock->expects($this->never())\n ->method('updateRecord');\n\n $fieldDataRepository = $this->createMock(FieldDataRepository::class);\n $fieldDataRepository->method('getActivityFieldData')->willReturn(collect([]));\n app()->instance(FieldDataRepository::class, $fieldDataRepository);\n\n $config = $this->createMock(Configuration::class);\n $profilesRelation = $this->getMockBuilder(\\Illuminate\\Database\\Eloquent\\Relations\\HasMany::class)\n ->disableOriginalConstructor()\n ->onlyMethods(['get'])\n ->addMethods(['where'])\n ->getMock();\n $profilesRelation->method('where')->willReturnSelf();\n $profilesRelation->method('get')->willReturn(collect([]));\n $config->method('profiles')->willReturn($profilesRelation);\n\n $serviceMock->config = $config;\n $serviceMock->profile = null;\n\n $actualStartTime = \\Carbon\\Carbon::now();\n\n $activity = $this->getMockBuilder(Activity::class)\n ->disableOriginalConstructor()\n ->onlyMethods(['update', 'hasProspect'])\n ->getMock();\n\n $activity->method('update')->willReturn(true);\n $activity->method('hasProspect')->willReturn(true);\n\n $activity->type = Activity::TYPE_CONFERENCE;\n $activity->provider = Activity::PROVIDER_TWILIO;\n $activity->lead_id = 1;\n $activity->user_id = 0;\n $activity->id_string = 'test-activity-id';\n $activity->user = $user;\n\n $activity->actual_start_time = $actualStartTime;\n $activity->uuid = 'c53d8320-f556-4cee-a2f8-5f232f454ca4';\n\n app()->bind(PlaybookResolver::class, function () use ($user) {\n $playbook = $this->createMock(Playbook::class);\n $playbookResolver = $this->createMock(PlaybookResolver::class);\n $playbookResolver->expects($this->once())\n ->method('resolvePlaybookByUser')\n ->with($user)\n ->willReturn($playbook);\n\n return $playbookResolver;\n });\n\n $data = $serviceMock->fetchAndAssociateRelatedActivity($activity);\n\n $this->assertInstanceOf(Activity::class, $data);\n $this->assertEquals(Activity::TYPE_CONFERENCE, $data->getType());\n $this->assertEquals($actualStartTime->getTimestamp(), $data->getActualStartTime()->getTimestamp());\n }\n\n public function testFetchAndAssociateRelatedActivitySkipsForTaskBasedPlaybook(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods(['fetchRelatedActivity', 'getPlaybook'])\n ->getMock();\n\n $user = $this->createMock(User::class);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n $playbook->method('getId')->willReturn(123);\n\n $serviceMock->expects($this->once())\n ->method('getPlaybook')\n ->with($user)\n ->willReturn($playbook);\n\n $serviceMock->expects($this->never())\n ->method('fetchRelatedActivity');\n\n $activity = $this->getMockBuilder(Activity::class)\n ->disableOriginalConstructor()\n ->onlyMethods(['hasProspect', 'getUuid'])\n ->getMock();\n\n $activity->method('hasProspect')->willReturn(true);\n $activity->method('getUuid')->willReturn('c53d8320-f556-4cee-a2f8-5f232f454ca4');\n $activity->type = Activity::TYPE_CONFERENCE;\n $activity->actual_start_time = \\Carbon\\Carbon::now();\n $activity->user = $user;\n\n $result = $serviceMock->fetchAndAssociateRelatedActivity($activity);\n\n $this->assertNull($result);\n }\n\n public function testFetchAndAssociateRelatedActivityReturnsNullForNonConference(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $serviceMock = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)\n );\n\n $activity = $this->getMockBuilder(Activity::class)\n ->disableOriginalConstructor()\n ->getMock();\n\n $activity->type = Activity::TYPE_SOFTPHONE;\n\n $result = $serviceMock->fetchAndAssociateRelatedActivity($activity);\n\n $this->assertNull($result);\n }\n\n public function testFetchAndAssociateRelatedActivityReturnsNullWhenNoStartTime(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $serviceMock = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)\n );\n\n\n $activity = $this->getMockBuilder(Activity::class)\n ->disableOriginalConstructor()\n ->getMock();\n\n $activity->type = Activity::TYPE_CONFERENCE;\n $activity->actual_start_time = null;\n $activity->scheduled_start_time = null;\n\n $result = $serviceMock->fetchAndAssociateRelatedActivity($activity);\n\n $this->assertNull($result);\n }\n\n public function testFetchAndAssociateRelatedActivityReturnsNullWhenNoProspect(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods(['getPlaybook'])\n ->getMock();\n\n $serviceMock->expects($this->never())\n ->method('getPlaybook');\n\n $activity = $this->getMockBuilder(Activity::class)\n ->disableOriginalConstructor()\n ->onlyMethods(['hasProspect', 'getUuid'])\n ->getMock();\n\n $activity->method('hasProspect')->willReturn(false);\n $activity->method('getUuid')->willReturn('c53d8320-f556-4cee-a2f8-5f232f454ca4');\n $activity->type = Activity::TYPE_CONFERENCE;\n $activity->actual_start_time = \\Carbon\\Carbon::now();\n\n $result = $serviceMock->fetchAndAssociateRelatedActivity($activity);\n\n $this->assertNull($result);\n }\n\n public function testMatchExactlyByEmail(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods([])\n ->getMock();\n\n $profile = new Profile();\n $profile->setAttribute('id', bin2hex(random_bytes(8)));\n $serviceMock->profile = $profile;\n\n $team = $this->createMock(Team::class);\n $serviceMock->team = $team;\n\n $data = $serviceMock->matchExactlyByEmail(bin2hex(random_bytes(8)) . 'test_email@testserver.com');\n\n $this->assertEquals(null, $data);\n }\n\n public function testMatchDomainFromEmail(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $queryIterator = $this->createMock(QueryIterator::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $config = $this->createMock(Configuration::class);\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('search')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods(['convertCrmData'])\n ->getMock();\n\n $profile = new Profile();\n $profile->account_fields = 'Field1, Field2, Field3';\n $serviceMock->profile = $profile;\n\n $serviceMock->expects($this->once())\n ->method('convertCrmData')\n ->willReturn(['test']);\n\n $this->app->bind(QueryBuilder::class, function () {\n $queryBuilder = $this->createMock(QueryBuilder::class);\n $queryBuilder->expects($this->once())\n ->method('buildMatchByDomainQuery')\n ->with('test_email@testserver.com')\n ->willReturn('FIND {test_email@testserver.com} IN ALL FIELDS RETURNING Account(Id)');\n\n return $queryBuilder;\n });\n\n $team = $this->createMock(Team::class);\n\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n\n $data = $serviceMock->matchByDomain('test_email@testserver.com');\n\n $this->assertEquals(['test'], $data);\n }\n\n public function testBuildTaskSearchFields(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)\n );\n\n $fields = $service->buildTaskSearchFields();\n\n $expectedFields = ['Id', 'WhoId', 'WhatId', 'AccountId'];\n\n $this->assertEquals($expectedFields, $fields);\n }\n\n public function testMapCrmObjects(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class),\n );\n\n $sampleTask = [\n 'WhoId' => '003sampleWhoId',\n 'AccountId' => 'sampleAccountId',\n 'WhatId' => 'sampleWhatId',\n ];\n\n $activityData = $service->mapCrmObjects($sampleTask);\n\n $expectedActivityData = [\n 'contact' => '003sampleWhoId',\n 'account' => 'sampleAccountId',\n 'opportunity' => 'sampleWhatId',\n ];\n\n $this->assertEquals($expectedActivityData, $activityData);\n }\n\n public function testGetInstalledAppVersion(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n $queryIterator = $this->createMock(QueryIterator::class);\n $queryIterator->expects($this->any())\n ->method('current')->willReturn([\n 'SubscriberPackageVersion' => [\n 'MajorVersion' => '1',\n 'MinorVersion' => '0',\n 'PatchVersion' => '1',\n 'BuildNumber' => '0',\n ],\n ]);\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('metadata')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods(array_diff(get_class_methods(Service::class), ['getInstalledAppVersion']))\n ->getMock();\n\n $version = $serviceMock->getInstalledAppVersion();\n\n $this->assertEquals('1010', $version);\n }\n\n public function testSyncProfiles(): void\n {\n $userToSearch = null;\n\n $team = $this->createMock(Team::class);\n $config = $this->createMockedConfiguration();\n $config->expects($this->once())\n ->method('getId')\n ->willReturn(1);\n\n $salesforceUser = [\n 'Email' => 'test@example.com',\n 'UserPreferencesLightningExperiencePreferred' => true,\n 'CallCenterId' => '123',\n 'Id' => '456',\n 'ProfileId' => '789',\n ];\n\n app()->bind(QueryBuilder::class, function () use ($userToSearch) {\n $queryBuilder = $this->createMock(QueryBuilder::class);\n $queryBuilder->expects($this->once())\n ->method('buildGetUsersQuery')\n ->with($userToSearch)\n ->willReturn('SELECT * FROM Users');\n\n return $queryBuilder;\n });\n\n $queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('query')\n ->with('SELECT * FROM Users')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n\n $user = $this->createMock(User::class);\n $user->expects($this->once())\n ->method('getId')\n ->willReturn(123);\n\n $this->mockTeamRepository($team, $salesforceUser, $user);\n\n $profileRepository = $this->createMock(ProfileRepository::class);\n $profileRepository->expects($this->once())\n ->method('updateOrCreateProfile')\n ->with(\n $user,\n [\n 'crm_configuration_id' => 1,\n 'crm_provider_id' => '456',\n ],\n [\n 'user_id' => 123,\n 'edition' => Profile::EDITION_LIGHTNING,\n 'has_external_cti' => true,\n 'crm_profile_id' => '789',\n ]\n )\n ->willReturn(new Profile());\n\n $this->app->instance(ProfileRepository::class, $profileRepository);\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n $result = $serviceMock->syncProfiles($userToSearch);\n\n $this->assertNull($result);\n }\n\n public function testSyncProfilesEmailIsNull(): void\n {\n $userToSearch = $this->createMock(User::class);\n\n $salesforceUser = [\n 'Email' => null,\n ];\n\n app()->bind(QueryBuilder::class, function () {\n $queryBuilder = $this->createMock(QueryBuilder::class);\n $queryBuilder->expects($this->once())\n ->method('buildGetUsersQuery')\n ->with(null)\n ->willReturn('SELECT * FROM Users');\n\n return $queryBuilder;\n });\n\n $queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->exactly(2))\n ->method('query')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n\n $team = $this->createMock(Team::class);\n $user = $this->createMock(User::class);\n $this->mockTeamRepository($team, $salesforceUser, $user, false);\n\n $config = $this->createMock(Configuration::class);\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n\n $profile = $serviceMock->syncProfiles(null);\n\n $this->assertNull($profile);\n }\n\n public function testSyncProfilesUserToSearchMatchesCurrentUser(): void\n {\n $userToSearch = $this->createMock(User::class);\n $userToSearch->expects($this->once())\n ->method('getId')\n ->willReturn(123);\n\n $team = $this->createMock(Team::class);\n $config = $this->createMock(Configuration::class);\n $config->expects($this->once())\n ->method('getId')\n ->willReturn(1);\n\n $salesforceUser = [\n 'Email' => 'test@example.com',\n 'UserPreferencesLightningExperiencePreferred' => true,\n 'CallCenterId' => '123',\n 'Id' => '456',\n 'ProfileId' => '789',\n ];\n\n app()->bind(QueryBuilder::class, function () use ($userToSearch) {\n $queryBuilder = $this->createMock(QueryBuilder::class);\n $queryBuilder->expects($this->once())\n ->method('buildGetUsersQuery')\n ->with($userToSearch)\n ->willReturn('SELECT * FROM Users');\n\n return $queryBuilder;\n });\n\n $queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('query')\n ->with('SELECT * FROM Users')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n\n $user = $this->createMock(User::class);\n $user->expects($this->exactly(2))\n ->method('getId')\n ->willReturn(123);\n\n $this->mockTeamRepository($team, $salesforceUser, $user);\n\n $profileRepository = $this->createMock(ProfileRepository::class);\n $profileRepository->expects($this->once())\n ->method('updateOrCreateProfile')\n ->with(\n $user,\n [\n 'crm_configuration_id' => 1,\n 'crm_provider_id' => '456',\n ],\n [\n 'user_id' => 123,\n 'edition' => Profile::EDITION_LIGHTNING,\n 'has_external_cti' => true,\n 'crm_profile_id' => '789',\n ]\n )\n ->willReturn(new Profile());\n\n $this->app->instance(ProfileRepository::class, $profileRepository);\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n $profile = $serviceMock->syncProfiles($userToSearch);\n\n $this->assertInstanceOf(Profile::class, $profile);\n }\n\n public function testSyncProfilesWithCustomValidation(): void\n {\n $userToSearch = null;\n\n $team = $this->createMock(Team::class);\n $config = $this->createMockedConfiguration();\n $config->expects($this->atLeastOnce()) // Changed from once() to atLeastOnce()\n ->method('getId')\n ->willReturn(1);\n\n $salesforceUser = [\n 'Email' => 'test@example.com',\n 'UserPreferencesLightningExperiencePreferred' => true,\n 'CallCenterId' => '123',\n 'Id' => '456',\n 'ProfileId' => '789',\n 'CustomField' => 'CustomValue',\n ];\n\n $customRules = [\n ['field' => 'CustomField', 'value' => 'CustomValue'],\n ];\n\n $this->mockQueryBuilderAndHandler($userToSearch, [$salesforceUser]);\n\n $user = $this->createMock(User::class);\n $user->expects($this->once())\n ->method('getId')\n ->willReturn(123);\n\n $this->mockTeamRepository($team, $salesforceUser, $user, true, $customRules);\n\n $this->mockProfileRepository($user);\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n $profile = $serviceMock->syncProfiles($userToSearch);\n\n $this->assertNull($profile);\n }\n\n public function testSyncProfilesWithCustomValidationFailing(): void\n {\n $userToSearch = null;\n\n $team = $this->createMock(Team::class);\n $config = $this->createMockedConfiguration();\n\n $salesforceUser = [\n 'Email' => 'test@example.com',\n 'UserPreferencesLightningExperiencePreferred' => true,\n 'CallCenterId' => '123',\n 'Id' => '456',\n 'ProfileId' => '789',\n 'CustomField' => 'WrongValue',\n ];\n\n $customRules = [\n ['field' => 'CustomField', 'value' => 'CustomValue'],\n ];\n\n $this->mockQueryBuilderAndHandler($userToSearch, [$salesforceUser]);\n\n $teamRepository = $this->getMockForAbstractClass(TeamRepository::class, [], '', false, true, true, ['findActiveTeamMemberByEmail', 'getTeamSetting']);\n\n $teamSettings = $this->createMock(TeamSettings::class);\n $teamSettings->method('getValueType')\n ->willReturn('array');\n\n $teamSettings->method('getValue')\n ->willReturn(json_encode($customRules));\n\n $teamRepository->expects($this->once())\n ->method('getTeamSetting')\n ->with($team, 'custom_profile_validation')\n ->willReturn($teamSettings);\n\n app()->bind(TeamRepository::class, function () use ($teamRepository) {\n return $teamRepository;\n });\n\n $profileRepository = $this->createMock(ProfileRepository::class);\n $profileRepository->expects($this->never())\n ->method('updateOrCreateProfile');\n\n $this->app->instance(ProfileRepository::class, $profileRepository);\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n $result = $serviceMock->syncProfiles($userToSearch);\n\n $this->assertNull($result);\n }\n\n public function testGetContactRolesFromCrm(): void\n {\n $contactRoles = [\n [\n 'Id' => '1',\n 'ContactId' => 'Contact1',\n 'OpportunityId' => 'Opportunity1',\n 'Opportunity' => ['OwnerId' => 'Owner1'],\n 'IsPrimary' => true,\n 'Role' => 'Decision Maker',\n ],\n ];\n\n $expectedResponse = [\n [\n 'id' => '1',\n 'contactId' => 'Contact1',\n 'opportunityId' => 'Opportunity1',\n 'ownerId' => 'Owner1',\n 'isPrimary' => true,\n 'role' => 'Decision Maker',\n ],\n ];\n\n $this->bindQueryIterator($contactRoles);\n\n $serviceMock = $this->getServiceMock();\n\n $data = $serviceMock->getContactRolesFromCrm(now()->subDay());\n\n $this->assertEquals($expectedResponse, $data);\n }\n\n public function testGetContactRolesFromCrmNoResult(): void\n {\n $this->bindQueryIterator([]);\n\n $serviceMock = $this->getServiceMock();\n\n $data = $serviceMock->getContactRolesFromCrm(now()->subDay());\n\n $this->assertEquals([], $data);\n }\n\n public function testSyncContactRoles(): void\n {\n $contactRoles = [\n [\n 'id' => '1',\n 'contactId' => 'Contact1',\n 'opportunityId' => 'Opportunity1',\n 'ownerId' => 'Owner1',\n 'isPrimary' => true,\n 'role' => 'Decision Maker',\n ],\n ];\n\n app()->bind(ContactRoleRepository::class, function () {\n $contactRoleRepository = $this->createMock(ContactRoleRepository::class);\n $contactRoleRepository->expects($this->once())\n ->method('saveContactRoles');\n\n return $contactRoleRepository;\n });\n\n $serviceMock = $this->getServiceMock([\n 'getContactRolesFromCrm',\n 'syncRemotelyDeletedContactRoles',\n 'syncContact',\n 'syncOpportunity',\n ]);\n\n $config = $this->createMock(Configuration::class);\n $hasMany = $this->createMock(HasManyExtended::class);\n $hasMany->expects($this->exactly(2))\n ->method('where')\n ->willReturn($hasMany);\n\n $hasMany->expects($this->exactly(2))\n ->method('first')\n ->willReturn(\n $this->createMock(Contact::class),\n $this->createMock(Opportunity::class)\n );\n\n $config->expects($this->once())\n ->method('contacts')\n ->willReturn($hasMany);\n $config->expects($this->once())\n ->method('opportunities')\n ->willReturn($hasMany);\n\n $serviceMock->config = $config;\n\n $serviceMock->expects($this->once())\n ->method('getContactRolesFromCrm')\n ->willReturn($contactRoles);\n\n $serviceMock->expects($this->once())\n ->method('syncRemotelyDeletedContactRoles');\n\n $data = $serviceMock->syncContactRoles(now()->subDay());\n\n $this->assertEquals(1, $data);\n }\n\n public function testSyncRemotelyDeletedContactRoles(): void\n {\n $contactRoles = [\n [\n 'id' => '1',\n 'crm_provider_id' => '1',\n ],\n ];\n\n app()->bind(QueryHandler::class, function () use ($contactRoles) {\n $queryResults = new QueryResults($contactRoles, 1, true, null);\n\n $handler = $this->createMock(QueryHandler::class);\n $handler->method('queryDeleted')\n ->willReturn($queryResults);\n\n return $handler;\n });\n\n app()->bind(ContactRoleRepository::class, function () {\n $contactRoleRepository = $this->createMock(ContactRoleRepository::class);\n $contactRoleRepository->expects($this->once())\n ->method('deleteContactRoles');\n\n return $contactRoleRepository;\n });\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $this->createMock(Team::class);\n\n $data = $this->invokePrivateMethod('syncRemotelyDeletedContactRoles', $serviceMock, []);\n\n $this->assertTrue($data);\n }\n\n private function bindQueryIterator(array $queryResult): void\n {\n /** @var Client $client */\n $client = $this->createMock(Client::class);\n $queryIterator = new QueryIterator(\n $client,\n new QueryResults($queryResult, 1, true, null)\n );\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('query')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n }\n\n public static function getOpportunitySortOrderDataProvider(): array\n {\n return [\n 'all open recently updated' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED, ['LastModifiedDate DESC', true]],\n 'all open recently created' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED, ['CreatedDate DESC', true]],\n 'all open oldest created' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED, ['CreatedDate ASC', true]],\n 'all recently updated' => [Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED, ['LastModifiedDate DESC', false]],\n 'default' => ['unknown', ['LastModifiedDate DESC', true]],\n ];\n }\n\n private function createMockedConfiguration(): Configuration\n {\n $config = $this->createMock(Configuration::class);\n $profilesRelation = $this->getMockBuilder(\\Illuminate\\Database\\Eloquent\\Relations\\HasMany::class)\n ->disableOriginalConstructor()\n ->onlyMethods(['get'])\n ->addMethods(['where', 'first'])\n ->getMock();\n $profilesRelation->method('where')->willReturnSelf();\n $profilesRelation->method('get')->willReturn(collect([]));\n $profilesRelation->method('first')->willReturn(null);\n $config->method('profiles')->willReturn($profilesRelation);\n\n return $config;\n }\n\n private function getServiceMock(array $onlyMethods = []): MockObject&Service\n {\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $this->createMock(Client::class),\n $this->createMock(PayloadBuilder::class),\n $this->createMock(Dispatcher::class),\n $this->createMock(CountriesMap::class),\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods($onlyMethods)\n ->getMock();\n\n $serviceMock->profile = $this->createMock(Profile::class);\n\n return $serviceMock;\n }\n\n private function mockQueryBuilderAndHandler($userToSearch, $salesforceUsers): void\n {\n app()->bind(QueryBuilder::class, function () use ($userToSearch) {\n $queryBuilder = $this->createMock(QueryBuilder::class);\n $queryBuilder->expects($this->once())\n ->method('buildGetUsersQuery')\n ->with($userToSearch)\n ->willReturn('SELECT * FROM Users');\n\n return $queryBuilder;\n });\n\n $queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults($salesforceUsers, count($salesforceUsers), true, null));\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('query')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n }\n\n private function mockTeamRepository(\n Team $team,\n array $salesforceUser,\n ?User $user = null,\n bool $userSearch = true,\n array $customRules = []\n ): void {\n $teamRepository = $this->getMockForAbstractClass(TeamRepository::class, [], '', false, true, true, ['findActiveTeamMemberByEmail', 'getTeamSetting']);\n\n if ($userSearch) {\n $teamRepository->expects($this->once())\n ->method('findActiveTeamMemberByEmail')\n ->with($team, $salesforceUser['Email'])\n ->willReturn($user);\n }\n\n $teamSettings = $this->createMock(TeamSettings::class);\n $teamSettings->method('getValueType')\n ->willReturn('array');\n\n $teamSettings->method('getValue')\n ->willReturn(json_encode($customRules));\n\n $teamRepository->expects($this->once())\n ->method('getTeamSetting')\n ->with($team, 'custom_profile_validation')\n ->willReturn($teamSettings);\n\n app()->bind(TeamRepository::class, function () use ($teamRepository) {\n return $teamRepository;\n });\n }\n\n private function mockProfileRepository(User $user): void\n {\n $profileRepository = $this->createMock(ProfileRepository::class);\n $profileRepository->expects($this->once())\n ->method('updateOrCreateProfile')\n ->with(\n $user,\n [\n 'crm_configuration_id' => 1,\n 'crm_provider_id' => '456',\n ],\n [\n 'user_id' => 123,\n 'edition' => Profile::EDITION_LIGHTNING,\n 'has_external_cti' => true,\n 'crm_profile_id' => '789',\n ]\n )\n ->willReturn(new Profile());\n\n $this->app->instance(ProfileRepository::class, $profileRepository);\n }\n\n public function testBuildEnhancedNoteDecodesHtmlEntities(): void\n {\n $service = $this->getServiceMock(['createRecord']);\n\n $profile = new Profile();\n $profile->setAttribute('crm_provider_id', 'owner-123');\n $service->profile = $profile;\n\n $service->expects($this->exactly(2))\n ->method('createRecord')\n ->willReturnOnConsecutiveCalls('note-id-123', 'link-id-456');\n\n $bodyWithEntities = 'Welch's current challenges and Facebook's Club';\n\n $result = $this->invokePrivateMethod('buildEnhancedNote', $service, [\n 'Test Title',\n $bodyWithEntities,\n 'object-id-789',\n ]);\n\n $this->assertEquals('note-id-123', $result);\n }\n\n public function testBuildEnhancedNoteSanitizesWithoutQuotes(): void\n {\n $service = $this->getServiceMock(['createRecord']);\n\n $profile = new Profile();\n $profile->setAttribute('crm_provider_id', 'owner-456');\n $service->profile = $profile;\n\n $service->expects($this->exactly(2))\n ->method('createRecord')\n ->willReturnCallback(function ($type, $data) {\n if ($type === 'ContentNote') {\n $decoded = base64_decode($data['Content']);\n $this->assertStringContainsString(\"Welch's\", $decoded);\n $this->assertStringNotContainsString(''', $decoded);\n $this->assertStringNotContainsString('&#039;', $decoded);\n $this->assertStringContainsString('<script>', $decoded);\n\n return 'note-id-456';\n }\n\n return 'link-id-789';\n });\n\n $bodyWithMixedContent = \"Welch's and <script>alert('xss')</script>\";\n\n $result = $this->invokePrivateMethod('buildEnhancedNote', $service, [\n 'Test Title',\n $bodyWithMixedContent,\n 'object-id-123',\n ]);\n\n $this->assertEquals('note-id-456', $result);\n }\n\n public function testBuildEnhancedNoteConvertsLineBreaks(): void\n {\n $service = $this->getServiceMock(['createRecord']);\n\n $profile = new Profile();\n $profile->setAttribute('crm_provider_id', 'owner-789');\n $service->profile = $profile;\n\n $service->expects($this->exactly(2))\n ->method('createRecord')\n ->willReturnCallback(function ($type, $data) {\n if ($type === 'ContentNote') {\n $decoded = base64_decode($data['Content']);\n $this->assertStringContainsString('<br>', $decoded);\n $this->assertStringNotContainsString('<br />', $decoded);\n\n return 'note-id-789';\n }\n\n return 'link-id-012';\n });\n\n $bodyWithLineBreaks = \"Line 1\\nLine 2\\nLine 3\";\n\n $result = $this->invokePrivateMethod('buildEnhancedNote', $service, [\n 'Test Title',\n $bodyWithLineBreaks,\n 'object-id-456',\n ]);\n\n $this->assertEquals('note-id-789', $result);\n }\n\n public function testBuildEnhancedNoteHandlesComplexScenario(): void\n {\n $service = $this->getServiceMock(['createRecord']);\n\n $profile = new Profile();\n $profile->setAttribute('crm_provider_id', 'owner-complex');\n $service->profile = $profile;\n\n $service->expects($this->exactly(2))\n ->method('createRecord')\n ->willReturnCallback(function ($type, $data) {\n if ($type === 'ContentNote') {\n $decoded = base64_decode($data['Content']);\n\n $this->assertStringContainsString(\"Welch's\", $decoded);\n $this->assertStringContainsString(\"Facebook's Club\", $decoded);\n $this->assertStringContainsString(\"Arctics'\", $decoded);\n $this->assertStringNotContainsString(''', $decoded);\n $this->assertStringNotContainsString('&#039;', $decoded);\n $this->assertStringContainsString('<br>', $decoded);\n $this->assertStringContainsString('<', $decoded);\n $this->assertStringContainsString('>', $decoded);\n\n return 'note-complex';\n }\n\n return 'link-complex';\n });\n\n $complexBody = \"Summary:\\n---------\\nThe call focused on understanding Welch's current challenges and exploring how Arctics' Revenue Growth Management solutions could support their strategic goals.\\n\\n• John SMith discussed his role as a category advisor for Google and Facebook's Club, emphasizing the importance of market research and advising on product assortment.\\n• Madona introduced Arctics' Virtual Shoppers AI, which simulates consumer <behavior> to optimize pricing and promotional strategies.\";\n\n $result = $this->invokePrivateMethod('buildEnhancedNote', $service, [\n 'Jiminny Transcription Summary',\n $complexBody,\n 'task-id-001',\n ]);\n\n $this->assertEquals('note-complex', $result);\n }\n\n public function testSyncRemotelyDeletedObjectsWithErrorHandlingSuccess(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['syncRemotelyDeletedObjects'])\n ->getMock();\n\n // Mock team\n $team = $this->createMock(Team::class);\n $team->method('getUuid')->willReturn('team-uuid-123');\n $serviceMock->team = $team;\n\n // Expect syncRemotelyDeletedObjects to be called once and succeed\n $serviceMock->expects($this->once())\n ->method('syncRemotelyDeletedObjects')\n ->with(\\Jiminny\\Enums\\CrmObject::ACCOUNT);\n\n // Call the protected method using reflection\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');\n $method->setAccessible(true);\n\n // Should not throw any exceptions\n $method->invoke($serviceMock, \\Jiminny\\Enums\\CrmObject::ACCOUNT);\n\n $this->assertTrue(true); // Test completed successfully\n }\n\n public function testSyncRemotelyDeletedObjectsWithErrorHandlingFailure(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['syncRemotelyDeletedObjects'])\n ->getMock();\n\n // Mock team\n $team = $this->createMock(Team::class);\n $team->method('getUuid')->willReturn('team-uuid-456');\n $serviceMock->team = $team;\n\n // Mock logger to verify warning is logged\n $logger = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n\n // Use reflection to set the protected logger property\n $reflection = new \\ReflectionClass($serviceMock);\n $loggerProperty = $reflection->getProperty('logger');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($serviceMock, $logger);\n\n $exception = new \\Exception('Sync failed due to API error');\n\n // Expect syncRemotelyDeletedObjects to throw an exception\n $serviceMock->expects($this->once())\n ->method('syncRemotelyDeletedObjects')\n ->with(\\Jiminny\\Enums\\CrmObject::CONTACT)\n ->willThrowException($exception);\n\n // Expect warning to be logged with correct message and parameters\n $logger->expects($this->once())\n ->method('warning')\n ->with(\n '[Salesforce] Remotely deleted objects sync failed',\n [\n 'objectType' => 'contact',\n 'teamId' => 'team-uuid-456',\n 'reason' => 'Sync failed due to API error',\n ]\n );\n\n // Call the protected method using reflection\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');\n $method->setAccessible(true);\n\n // Should not re-throw the exception, just log it\n $method->invoke($serviceMock, \\Jiminny\\Enums\\CrmObject::CONTACT);\n\n $this->assertTrue(true); // Test completed successfully\n }\n\n public function testSyncRemotelyDeletedObjectsWithErrorHandlingWithLogParams(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['syncRemotelyDeletedObjects'])\n ->getMock();\n\n // Mock team\n $team = $this->createMock(Team::class);\n $team->method('getUuid')->willReturn('team-uuid-789');\n $serviceMock->team = $team;\n\n // Mock logger to verify warning is logged\n $logger = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n\n // Use reflection to set the protected logger property\n $loggerReflection = new \\ReflectionClass($serviceMock);\n $loggerProperty = $loggerReflection->getProperty('logger');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($serviceMock, $logger);\n\n $exception = new \\Exception('Network timeout');\n\n // Expect syncRemotelyDeletedObjects to throw an exception\n $serviceMock->expects($this->once())\n ->method('syncRemotelyDeletedObjects')\n ->with(\\Jiminny\\Enums\\CrmObject::OPPORTUNITY)\n ->willThrowException($exception);\n\n // Additional log parameters\n $logParams = [\n 'syncType' => 'full',\n 'batchSize' => 100,\n ];\n\n // Expect warning to be logged with merged parameters\n $logger->expects($this->once())\n ->method('warning')\n ->with(\n '[Salesforce] Remotely deleted objects sync failed',\n [\n 'objectType' => 'opportunity',\n 'teamId' => 'team-uuid-789',\n 'reason' => 'Network timeout',\n 'syncType' => 'full',\n 'batchSize' => 100,\n ]\n );\n\n // Call the protected method using reflection\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');\n $method->setAccessible(true);\n\n // Should not re-throw the exception, just log it\n $method->invoke($serviceMock, \\Jiminny\\Enums\\CrmObject::OPPORTUNITY, $logParams);\n\n $this->assertTrue(true); // Test completed successfully\n }\n\n /**\n * @dataProvider crmObjectProvider\n */\n public function testSyncRemotelyDeletedObjectsWithErrorHandlingDifferentCrmObjects(\\Jiminny\\Enums\\CrmObject $crmObject): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['syncRemotelyDeletedObjects'])\n ->getMock();\n\n // Mock team\n $team = $this->createMock(Team::class);\n $team->method('getUuid')->willReturn('team-uuid-test');\n $serviceMock->team = $team;\n\n // Mock logger to verify warning is logged\n $logger = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n\n // Use reflection to set the protected logger property\n $loggerReflectionClass = new \\ReflectionClass($serviceMock);\n $loggerProperty = $loggerReflectionClass->getProperty('logger');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($serviceMock, $logger);\n\n $exception = new \\Exception('Test error');\n\n // Expect syncRemotelyDeletedObjects to throw an exception\n $serviceMock->expects($this->once())\n ->method('syncRemotelyDeletedObjects')\n ->with($crmObject)\n ->willThrowException($exception);\n\n // Expect warning to be logged with correct entity type\n $logger->expects($this->once())\n ->method('warning')\n ->with(\n '[Salesforce] Remotely deleted objects sync failed',\n [\n 'objectType' => $crmObject->value,\n 'teamId' => 'team-uuid-test',\n 'reason' => 'Test error',\n ]\n );\n\n // Call the protected method using reflection\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');\n $method->setAccessible(true);\n\n $method->invoke($serviceMock, $crmObject);\n\n $this->assertTrue(true); // Test completed successfully\n }\n\n public function testHandleObjectDeletionWithDeletedEntity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['deleteCrmObject'])\n ->getMock();\n\n $entity = $this->createMock(\\Jiminny\\Models\\Account::class);\n $crmData = ['IsDeleted' => true];\n\n $serviceMock->expects($this->once())\n ->method('deleteCrmObject')\n ->with($entity);\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('handleObjectDeletion');\n $method->setAccessible(true);\n\n $method->invoke($serviceMock, $entity, $crmData);\n }\n\n public function testHandleObjectDeletionWithNonDeletedEntity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['deleteCrmObject'])\n ->getMock();\n\n $entity = $this->createMock(\\Jiminny\\Models\\Contact::class);\n $crmData = ['IsDeleted' => false];\n\n $serviceMock->expects($this->never())\n ->method('deleteCrmObject');\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('handleObjectDeletion');\n $method->setAccessible(true);\n\n $method->invoke($serviceMock, $entity, $crmData);\n }\n\n public function testDeleteCrmObjectWithValidEntity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['dispatchDeleteCrmObjectJob'])\n ->getMock();\n\n $entity = $this->createMock(\\Jiminny\\Models\\Lead::class);\n $entity->expects($this->once())->method('delete');\n\n $serviceMock->expects($this->once())\n ->method('dispatchDeleteCrmObjectJob')\n ->with($entity);\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('deleteCrmObject');\n $method->setAccessible(true);\n\n $method->invoke($serviceMock, $entity);\n }\n\n public function testDeleteCrmObjectWithNullEntity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['dispatchDeleteCrmObjectJob'])\n ->getMock();\n\n $serviceMock->expects($this->never())\n ->method('dispatchDeleteCrmObjectJob');\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('deleteCrmObject');\n $method->setAccessible(true);\n\n $method->invoke($serviceMock, null);\n }\n\n public function testDispatchDeleteCrmObjectJobWithNullEntity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($service);\n $method = $reflection->getMethod('dispatchDeleteCrmObjectJob');\n $method->setAccessible(true);\n\n // Should return early without dispatching - no exception expected\n $method->invoke($service, null);\n\n $this->assertTrue(true); // Test completed successfully\n }\n\n public function testDispatchDeleteCrmObjectJobWithUnsupportedEntity(): void\n {\n $this->expectException(\\TypeError::class);\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n $unsupportedEntity = $this->createMock(\\stdClass::class);\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($service);\n $method = $reflection->getMethod('dispatchDeleteCrmObjectJob');\n\n // This will throw TypeError due to union type constraint\n $method->invoke($service, $unsupportedEntity);\n }\n\n public function testHandleEntityDeletionByProviderIdMethodExists(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n // Test that the method exists and is accessible via reflection\n $reflection = new \\ReflectionClass($service);\n $method = $reflection->getMethod('handleEntityDeletionByProviderId');\n $method->setAccessible(true);\n\n // Verify method exists and has correct parameters\n $this->assertTrue($method->isProtected());\n $this->assertEquals(2, $method->getNumberOfParameters());\n\n $parameters = $method->getParameters();\n $this->assertEquals('targetEntity', $parameters[0]->getName());\n $this->assertEquals('crmData', $parameters[1]->getName());\n }\n\n public function testSyncRemotelyDeletedObjectsWithNoResults(): void\n {\n // Create a real service instance to avoid mock property issues\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n // Mock queryHandler to throw NoResultsException\n $queryHandler = $this->createMock(QueryHandler::class);\n $queryHandler->expects($this->once())\n ->method('queryDeleted')\n ->with('Opportunity')\n ->willThrowException(new NoResultsException('No results'));\n\n // Set the queryHandler using reflection on the real service\n $reflection = new \\ReflectionClass($service);\n $queryHandlerProperty = $reflection->getProperty('queryHandler');\n $queryHandlerProperty->setAccessible(true);\n $queryHandlerProperty->setValue($service, $queryHandler);\n\n $result = self::invokePrivateMethod('syncRemotelyDeletedObjects', $service, [CrmObject::OPPORTUNITY]);\n\n $this->assertFalse($result);\n }\n\n public function testSyncRemotelyDeletedObjectsWithEmptyResults(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n // Mock queryHandler to return empty results\n $queryResult = $this->createMock(QueryResults::class);\n $queryResult->method('getResults')->willReturn([]);\n\n $queryHandler = $this->createMock(QueryHandler::class);\n $queryHandler->expects($this->once())\n ->method('queryDeleted')\n ->with('Opportunity')\n ->willReturn($queryResult);\n\n // Set the queryHandler using reflection on the real service\n $reflection = new \\ReflectionClass($service);\n $queryHandlerProperty = $reflection->getProperty('queryHandler');\n $queryHandlerProperty->setAccessible(true);\n $queryHandlerProperty->setValue($service, $queryHandler);\n\n $result = self::invokePrivateMethod('syncRemotelyDeletedObjects', $service, [CrmObject::OPPORTUNITY]);\n\n $this->assertFalse($result);\n }\n\n public function testSyncRemotelyDeletedObjectsWithUnsupportedCrmObject(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n // Mock queryHandler to return some deleted objects so we reach the match statement\n $deletedObjects = [\n ['id' => 'task1'],\n ['id' => 'task2'],\n ];\n $queryResult = $this->createMock(QueryResults::class);\n $queryResult->method('getResults')->willReturn($deletedObjects);\n\n $queryHandler = $this->createMock(QueryHandler::class);\n $queryHandler->expects($this->once())\n ->method('queryDeleted')\n ->with('Task') // ucfirst('task') = 'Task'\n ->willReturn($queryResult);\n\n self::setPrivateProperty($service, 'queryHandler', $queryHandler);\n\n $this->expectException(InvalidArgumentException::class);\n $this->expectExceptionMessage('Unsupported CrmObject: task');\n\n self::invokePrivateMethod('syncRemotelyDeletedObjects', $service, [CrmObject::TASK]);\n }\n\n public static function crmObjectProvider(): array\n {\n return [\n 'Account' => [CrmObject::ACCOUNT],\n 'Contact' => [CrmObject::CONTACT],\n 'Lead' => [CrmObject::LEAD],\n 'Opportunity' => [CrmObject::OPPORTUNITY],\n ];\n }\n\n public function testVerifyTaskExistsReturnsTrue(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-123', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-123');\n $activity->method('getId')->willReturn(456);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-123', ['Id', 'IsDeleted'])\n ->willReturn(['Id' => 'task-123', 'IsDeleted' => false]);\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertTrue($result);\n }\n\n public function testVerifyTaskExistsReturnsTrueForEvent(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:event-123', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('event-123');\n $activity->method('getId')->willReturn(456);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_EVENT);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Event', 'event-123', ['Id', 'IsDeleted'])\n ->willReturn(['Id' => 'event-123', 'IsDeleted' => false]);\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertTrue($result);\n }\n\n public function testVerifyTaskExistsReturnsFalseWhenDeleted(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-456', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-456');\n $activity->method('getId')->willReturn(789);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-456', ['Id', 'IsDeleted'])\n ->willReturn(['Id' => 'task-456', 'IsDeleted' => true]);\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertFalse($result);\n }\n\n public function testVerifyTaskExistsReturnsFalseWhenNotFound(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-999', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-999');\n $activity->method('getId')->willReturn(999);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-999', ['Id', 'IsDeleted'])\n ->willThrowException(new \\Jiminny\\Exceptions\\HttpNotFoundException('Task not found'));\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertFalse($result);\n }\n\n public function testVerifyTaskExistsReturnsFalseWhenNoPlaybook(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-no-playbook', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-no-playbook');\n $activity->method('getId')->willReturn(111);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn(null);\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertFalse($result);\n }\n\n public function testVerifyTaskExistsThrowsExceptionForTransientErrors(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-error', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-error');\n $activity->method('getId')->willReturn(888);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-error', ['Id', 'IsDeleted'])\n ->willThrowException(new \\RuntimeException('Network timeout'));\n\n $this->expectException(\\RuntimeException::class);\n $this->expectExceptionMessage('Network timeout');\n\n $service->verifyTaskExists($activity);\n }\n\n public function testVerifyTaskExistsCachesResults(): void\n {\n $cachedValue = null;\n Cache::shouldReceive('remember')\n ->twice()\n ->with('crm_task_exists:123:task-cached', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(function ($key, $ttl, $callback) use (&$cachedValue) {\n if ($cachedValue === null) {\n $cachedValue = $callback();\n }\n\n return $cachedValue;\n });\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-cached');\n $activity->method('getId')->willReturn(555);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-cached', ['Id', 'IsDeleted'])\n ->willReturn(['Id' => 'task-cached', 'IsDeleted' => false]);\n\n $result1 = $service->verifyTaskExists($activity);\n $result2 = $service->verifyTaskExists($activity);\n\n $this->assertTrue($result1);\n $this->assertTrue($result2);\n }\n\n public function testVerifyTaskExistsReturnsFalseForHttpBadRequestException(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-400', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-400');\n $activity->method('getId')->willReturn(400);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-400', ['Id', 'IsDeleted'])\n ->willThrowException(new \\Jiminny\\Exceptions\\HttpBadRequestException('Bad request'));\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertFalse($result);\n }\n\n public function testImportOpportunitySkipsWhenNoProfileAndNoAccount(): void\n {\n $crmData = [\n 'Id' => 'SF-NO-USER-1',\n 'Name' => 'Test Opportunity',\n 'OwnerId' => 'owner-no-profile',\n // No AccountId\n ];\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(\\Illuminate\\Events\\Dispatcher::class); // ← ADD THIS\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n\n $service = new Service(\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService\n );\n\n $config = $this->createMock(Configuration::class);\n\n // Mock profiles relation returning null (no profile found)\n $profilesRelation = \\Mockery::mock(\\Illuminate\\Database\\Eloquent\\Relations\\HasMany::class);\n $profilesRelation->shouldReceive('where')->with('crm_provider_id', 'owner-no-profile')->andReturnSelf();\n $profilesRelation->shouldReceive('first')->andReturn(null);\n\n $config->expects($this->once())\n ->method('profiles')\n ->willReturn($profilesRelation);\n\n $team = $this->createMock(Team::class);\n $team->method('getId')->willReturn(1);\n\n $logger = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n $logger->expects($this->once())\n ->method('error')\n ->with(\n '[Salesforce] | Skip import, no user_id found',\n ['id' => 'SF-NO-USER-1']\n );\n\n $reflection = new \\ReflectionClass($service);\n\n $configProperty = $reflection->getProperty('config');\n $configProperty->setAccessible(true);\n $configProperty->setValue($service, $config);\n\n $teamProperty = $reflection->getProperty('team');\n $teamProperty->setAccessible(true);\n $teamProperty->setValue($service, $team);\n\n $loggerProperty = $reflection->getProperty('logger');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($service, $logger);\n\n // Initialize profile property to avoid \"must not be accessed before initialization\" error\n $profileProperty = $reflection->getProperty('profile');\n $profileProperty->setAccessible(true);\n $profileProperty->setValue($service, null);\n\n $result = self::invokePrivateMethod('importOpportunity', $service, [$crmData]);\n\n $this->assertNull($result);\n }\n\n public function testImportOpportunitySkipsWhenProfileNotFound(): void\n {\n $crmData = [\n 'Id' => 'SF-NO-USER-2',\n 'Name' => 'Test Opportunity',\n 'OwnerId' => 'owner-not-found',\n // No AccountId - avoid complex account processing\n ];\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $eventDispatcher = $this->createMock(\\Illuminate\\Events\\Dispatcher::class); // ← ADD THIS\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n\n\n $service = new Service(\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService\n );\n\n $config = $this->createMock(Configuration::class);\n\n // Mock profiles relation returning null (no profile found)\n $profilesRelation = \\Mockery::mock(\\Illuminate\\Database\\Eloquent\\Relations\\HasMany::class);\n $profilesRelation->shouldReceive('where')->with('crm_provider_id', 'owner-not-found')->andReturnSelf();\n $profilesRelation->shouldReceive('first')->andReturn(null);\n\n $config->expects($this->once())\n ->method('profiles')\n ->willReturn($profilesRelation);\n\n $team = $this->createMock(Team::class);\n $team->method('getId')->willReturn(1);\n\n $logger = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n $logger->expects($this->once())\n ->method('error')\n ->with(\n '[Salesforce] | Skip import, no user_id found',\n ['id' => 'SF-NO-USER-2']\n );\n\n $reflection = new \\ReflectionClass($service);\n\n $configProperty = $reflection->getProperty('config');\n $configProperty->setAccessible(true);\n $configProperty->setValue($service, $config);\n\n $teamProperty = $reflection->getProperty('team');\n $teamProperty->setAccessible(true);\n $teamProperty->setValue($service, $team);\n\n $loggerProperty = $reflection->getProperty('logger');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($service, $logger);\n\n // Initialize profile property\n $profileProperty = $reflection->getProperty('profile');\n $profileProperty->setAccessible(true);\n $profileProperty->setValue($service, null);\n\n $result = self::invokePrivateMethod('importOpportunity', $service, [$crmData]);\n\n $this->assertNull($result);\n }\n}\n\nclass HasManyExtended extends HasMany\n{\n public function where()\n {\n }\n\n public function first()\n {\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"A","depth":4,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":5,"bounds":{"left":0.0,"top":0.0,"width":0.008333334,"height":0.03},"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Select All","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"320","depth":6,"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"320","depth":7,"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"5772","depth":8,"bounds":{"left":0.0,"top":0.0,"width":0.2048611,"height":0.024444444},"on_screen":false,"value":"5772","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"46ea38f7-c940-395d-9c79-0329d30e9ab7","depth":6,"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"46ea38f7-c940-395d-9c79-0329d30e9ab7","depth":7,"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"2024-11-26 09:21:15","depth":8,"bounds":{"left":0.0,"top":0.0,"width":0.2048611,"height":0.024444444},"on_screen":false,"value":"2024-11-26 09:21:15","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"401","depth":6,"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"401","depth":7,"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"5772","depth":8,"bounds":{"left":0.0,"top":0.0,"width":0.2048611,"height":0.024444444},"on_screen":false,"value":"5772","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"<null>","depth":6,"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"<null>","depth":7,"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"5772","depth":8,"bounds":{"left":0.0,"top":0.0,"width":0.2048611,"height":0.024444444},"on_screen":false,"value":"5772","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"salesforce","depth":6,"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"salesforce","depth":7,"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"2024-11-26 09:21:15","depth":8,"bounds":{"left":0.0,"top":0.0,"width":0.2048611,"height":0.024444444},"on_screen":false,"value":"2024-11-26 09:21:15","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"enterprise","depth":6,"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"enterprise","depth":7,"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"2024-11-26 09:21:15","depth":8,"bounds":{"left":0.0,"top":0.0,"width":0.2048611,"height":0.024444444},"on_screen":false,"value":"2024-11-26 09:21:15","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"SWE38","depth":6,"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"SWE38","depth":7,"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"2024-11-26 09:21:15","depth":8,"bounds":{"left":0.0,"top":0.0,"width":0.2048611,"height":0.024444444},"on_screen":false,"value":"2024-11-26 09:21:15","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"0","depth":6,"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"0","depth":7,"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"5772","depth":8,"bounds":{"left":0.0,"top":0.0,"width":0.2048611,"height":0.024444444},"on_screen":false,"value":"5772","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"50.0","depth":6,"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"50.0","depth":7,"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"2024-11-26 09:21:15","depth":8,"bounds":{"left":0.0,"top":0.0,"width":0.2048611,"height":0.024444444},"on_screen":false,"value":"2024-11-26 09:21:15","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"01801","depth":6,"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"01801","depth":7,"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"2024-11-26 09:21:15","depth":8,"bounds":{"left":0.0,"top":0.0,"width":0.2048611,"height":0.024444444},"on_screen":false,"value":"2024-11-26 09:21:15","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"1","depth":6,"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"1","depth":7,"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"5772","depth":8,"bounds":{"left":0.0,"top":0.0,"width":0.2048611,"height":0.024444444},"on_screen":false,"value":"5772","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"1","depth":6,"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"1","depth":7,"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"5772","depth":8,"bounds":{"left":0.0,"top":0.0,"width":0.2048611,"height":0.024444444},"on_screen":false,"value":"5772","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"1","depth":6,"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"1","depth":7,"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"5772","depth":8,"bounds":{"left":0.0,"top":0.0,"width":0.2048611,"height":0.024444444},"on_screen":false,"value":"5772","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"00D20000000MdbgEAC","depth":6,"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"00D20000000MdbgEAC","depth":7,"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"2024-11-26 09:21:15","depth":8,"bounds":{"left":0.0,"top":0.0,"width":0.2048611,"height":0.024444444},"on_screen":false,"value":"2024-11-26 09:21:15","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"https://bipsolutions.my.salesforce.com","depth":6,"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"https://bipsolutions.my.salesforce.com","depth":7,"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"2024-11-26 09:21:15","depth":8,"bounds":{"left":0.0,"top":0.0,"width":0.2048611,"height":0.024444444},"on_screen":false,"value":"2024-11-26 09:21:15","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"2026-03-19 08:19:24","depth":6,"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"2026-03-19 08:19:24","depth":7,"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"2024-11-26 09:21:15","depth":8,"bounds":{"left":0.0,"top":0.0,"width":0.2048611,"height":0.024444444},"on_screen":false,"value":"2024-11-26 09:21:15","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"2026-05-22 09:48:21","depth":6,"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"2026-05-22 09:48:21","depth":7,"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"2024-11-26 09:21:15","depth":8,"bounds":{"left":0.0,"top":0.0,"width":0.2048611,"height":0.024444444},"on_screen":false,"value":"2024-11-26 09:21:15","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"2026-05-22 09:48:31","depth":6,"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"2026-05-22 09:48:31","depth":7,"on_screen":true,"role_description":"text"}]...
|
7732332105235147404
|
-6520523286572803295
|
idle
|
accessibility
|
NULL
|
Project: faVsco.js, menu
JY-20963-fix-import-on-de Project: faVsco.js, menu
JY-20963-fix-import-on-deleted-entity, menu
Start Listening for PHP Debug Connections
ServiceTest
Rerun 'PHPUnit: ServiceTest'
Debug 'ServiceTest'
Stop 'ServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Built-in Preview
Chrome
Firefox
Safari
Sync Changes
Hide This Notification
Code changed:
Hide
4
32
168
1
28
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Tests\Unit\Services\Crm\Salesforce;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Events\Dispatcher;
use Illuminate\Support\Facades\Cache;
use Jiminny\Component\Country\CountriesMap;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Enums\CrmObject;
use Jiminny\Exceptions\InvalidArgumentException;
use Jiminny\Exceptions\NoResultsException;
use Jiminny\Integrations\PlaybookResolver;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Playbook;
use Jiminny\Models\Team;
use Jiminny\Models\TeamSettings;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\ContactRoleRepository;
use Jiminny\Repositories\Crm\FieldDataRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\Salesforce\Client;
use Jiminny\Services\Crm\Salesforce\PayloadBuilder;
use Jiminny\Services\Crm\Salesforce\QueryBuilder;
use Jiminny\Services\Crm\Salesforce\QueryHandler;
use Jiminny\Services\Crm\Salesforce\QueryIterator;
use Jiminny\Services\Crm\Salesforce\QueryResults;
use Jiminny\Services\Crm\Salesforce\Service;
use PHPUnit\Framework\MockObject\MockObject;
use Tests\TestCase;
use Tests\Unit\Traits\TestPrivateMethod;
class ServiceTest extends TestCase
{
use TestPrivateMethod;
public function testFetchAndAssociateRelatedActivity(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$payloadBuilder->method('addCustomLogicFieldsPayload')
->willReturnCallback(function ($activity, $payload) {
return $payload;
});
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods(['fetchRelatedActivity', 'getPlaybook', 'getPlaybookCategory', 'updateRecord'])
->getMock();
$serviceMock->expects($this->once())
->method('fetchRelatedActivity')
->willReturn([
'Id' => 'testId',
'Type' => null,
'OwnerId' => 'testerUser',
'Description' => 'Test description',
]);
$user = $this->createMock(User::class);
$team = $this->createMock(Team::class);
$user->method('getAttribute')->with('team')->willReturn($team);
$playbook = $this->createMock(Playbook::class);
$playbook->method('getActivityField')->willReturn(null);
$playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_EVENT);
$serviceMock->expects($this->once())
->method('getPlaybook')
->with($user)
->willReturn($playbook);
$serviceMock->expects($this->never())
->method('getPlaybookCategory');
$serviceMock->expects($this->never())
->method('updateRecord');
$fieldDataRepository = $this->createMock(FieldDataRepository::class);
$fieldDataRepository->method('getActivityFieldData')->willReturn(collect([]));
app()->instance(FieldDataRepository::class, $fieldDataRepository);
$config = $this->createMock(Configuration::class);
$profilesRelation = $this->getMockBuilder(\Illuminate\Database\Eloquent\Relations\HasMany::class)
->disableOriginalConstructor()
->onlyMethods(['get'])
->addMethods(['where'])
->getMock();
$profilesRelation->method('where')->willReturnSelf();
$profilesRelation->method('get')->willReturn(collect([]));
$config->method('profiles')->willReturn($profilesRelation);
$serviceMock->config = $config;
$serviceMock->profile = null;
$actualStartTime = \Carbon\Carbon::now();
$activity = $this->getMockBuilder(Activity::class)
->disableOriginalConstructor()
->onlyMethods(['update', 'hasProspect'])
->getMock();
$activity->method('update')->willReturn(true);
$activity->method('hasProspect')->willReturn(true);
$activity->type = Activity::TYPE_CONFERENCE;
$activity->provider = Activity::PROVIDER_TWILIO;
$activity->lead_id = 1;
$activity->user_id = 0;
$activity->id_string = 'test-activity-id';
$activity->user = $user;
$activity->actual_start_time = $actualStartTime;
$activity->uuid = 'c53d8320-f556-4cee-a2f8-5f232f454ca4';
app()->bind(PlaybookResolver::class, function () use ($user) {
$playbook = $this->createMock(Playbook::class);
$playbookResolver = $this->createMock(PlaybookResolver::class);
$playbookResolver->expects($this->once())
->method('resolvePlaybookByUser')
->with($user)
->willReturn($playbook);
return $playbookResolver;
});
$data = $serviceMock->fetchAndAssociateRelatedActivity($activity);
$this->assertInstanceOf(Activity::class, $data);
$this->assertEquals(Activity::TYPE_CONFERENCE, $data->getType());
$this->assertEquals($actualStartTime->getTimestamp(), $data->getActualStartTime()->getTimestamp());
}
public function testFetchAndAssociateRelatedActivitySkipsForTaskBasedPlaybook(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods(['fetchRelatedActivity', 'getPlaybook'])
->getMock();
$user = $this->createMock(User::class);
$playbook = $this->createMock(Playbook::class);
$playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);
$playbook->method('getId')->willReturn(123);
$serviceMock->expects($this->once())
->method('getPlaybook')
->with($user)
->willReturn($playbook);
$serviceMock->expects($this->never())
->method('fetchRelatedActivity');
$activity = $this->getMockBuilder(Activity::class)
->disableOriginalConstructor()
->onlyMethods(['hasProspect', 'getUuid'])
->getMock();
$activity->method('hasProspect')->willReturn(true);
$activity->method('getUuid')->willReturn('c53d8320-f556-4cee-a2f8-5f232f454ca4');
$activity->type = Activity::TYPE_CONFERENCE;
$activity->actual_start_time = \Carbon\Carbon::now();
$activity->user = $user;
$result = $serviceMock->fetchAndAssociateRelatedActivity($activity);
$this->assertNull($result);
}
public function testFetchAndAssociateRelatedActivityReturnsNullForNonConference(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$serviceMock = new Service(
client: $client,
payloadBuilder: $payloadBuilder,
eventDispatcher: $eventDispatcher,
countriesMap: $countriesMap,
prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)
);
$activity = $this->getMockBuilder(Activity::class)
->disableOriginalConstructor()
->getMock();
$activity->type = Activity::TYPE_SOFTPHONE;
$result = $serviceMock->fetchAndAssociateRelatedActivity($activity);
$this->assertNull($result);
}
public function testFetchAndAssociateRelatedActivityReturnsNullWhenNoStartTime(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$serviceMock = new Service(
client: $client,
payloadBuilder: $payloadBuilder,
eventDispatcher: $eventDispatcher,
countriesMap: $countriesMap,
prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)
);
$activity = $this->getMockBuilder(Activity::class)
->disableOriginalConstructor()
->getMock();
$activity->type = Activity::TYPE_CONFERENCE;
$activity->actual_start_time = null;
$activity->scheduled_start_time = null;
$result = $serviceMock->fetchAndAssociateRelatedActivity($activity);
$this->assertNull($result);
}
public function testFetchAndAssociateRelatedActivityReturnsNullWhenNoProspect(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods(['getPlaybook'])
->getMock();
$serviceMock->expects($this->never())
->method('getPlaybook');
$activity = $this->getMockBuilder(Activity::class)
->disableOriginalConstructor()
->onlyMethods(['hasProspect', 'getUuid'])
->getMock();
$activity->method('hasProspect')->willReturn(false);
$activity->method('getUuid')->willReturn('c53d8320-f556-4cee-a2f8-5f232f454ca4');
$activity->type = Activity::TYPE_CONFERENCE;
$activity->actual_start_time = \Carbon\Carbon::now();
$result = $serviceMock->fetchAndAssociateRelatedActivity($activity);
$this->assertNull($result);
}
public function testMatchExactlyByEmail(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods([])
->getMock();
$profile = new Profile();
$profile->setAttribute('id', bin2hex(random_bytes(8)));
$serviceMock->profile = $profile;
$team = $this->createMock(Team::class);
$serviceMock->team = $team;
$data = $serviceMock->matchExactlyByEmail(bin2hex(random_bytes(8)) . '[EMAIL]');
$this->assertEquals(null, $data);
}
public function testMatchDomainFromEmail(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$queryIterator = $this->createMock(QueryIterator::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$config = $this->createMock(Configuration::class);
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('search')
->willReturn($queryIterator);
return $handler;
});
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods(['convertCrmData'])
->getMock();
$profile = new Profile();
$profile->account_fields = 'Field1, Field2, Field3';
$serviceMock->profile = $profile;
$serviceMock->expects($this->once())
->method('convertCrmData')
->willReturn(['test']);
$this->app->bind(QueryBuilder::class, function () {
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->expects($this->once())
->method('buildMatchByDomainQuery')
->with('[EMAIL]')
->willReturn('FIND {[EMAIL]} IN ALL FIELDS RETURNING Account(Id)');
return $queryBuilder;
});
$team = $this->createMock(Team::class);
$serviceMock->team = $team;
$serviceMock->config = $config;
$data = $serviceMock->matchByDomain('[EMAIL]');
$this->assertEquals(['test'], $data);
}
public function testBuildTaskSearchFields(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$service = new Service(
client: $client,
payloadBuilder: $payloadBuilder,
eventDispatcher: $eventDispatcher,
countriesMap: $countriesMap,
prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)
);
$fields = $service->buildTaskSearchFields();
$expectedFields = ['Id', 'WhoId', 'WhatId', 'AccountId'];
$this->assertEquals($expectedFields, $fields);
}
public function testMapCrmObjects(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$service = new Service(
client: $client,
payloadBuilder: $payloadBuilder,
eventDispatcher: $eventDispatcher,
countriesMap: $countriesMap,
prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class),
);
$sampleTask = [
'WhoId' => '003sampleWhoId',
'AccountId' => 'sampleAccountId',
'WhatId' => 'sampleWhatId',
];
$activityData = $service->mapCrmObjects($sampleTask);
$expectedActivityData = [
'contact' => '003sampleWhoId',
'account' => 'sampleAccountId',
'opportunity' => 'sampleWhatId',
];
$this->assertEquals($expectedActivityData, $activityData);
}
public function testGetInstalledAppVersion(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$queryIterator = $this->createMock(QueryIterator::class);
$queryIterator->expects($this->any())
->method('current')->willReturn([
'SubscriberPackageVersion' => [
'MajorVersion' => '1',
'MinorVersion' => '0',
'PatchVersion' => '1',
'BuildNumber' => '0',
],
]);
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('metadata')
->willReturn($queryIterator);
return $handler;
});
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods(array_diff(get_class_methods(Service::class), ['getInstalledAppVersion']))
->getMock();
$version = $serviceMock->getInstalledAppVersion();
$this->assertEquals('1010', $version);
}
public function testSyncProfiles(): void
{
$userToSearch = null;
$team = $this->createMock(Team::class);
$config = $this->createMockedConfiguration();
$config->expects($this->once())
->method('getId')
->willReturn(1);
$salesforceUser = [
'Email' => '[EMAIL]',
'UserPreferencesLightningExperiencePreferred' => true,
'CallCenterId' => '123',
'Id' => '456',
'ProfileId' => '789',
];
app()->bind(QueryBuilder::class, function () use ($userToSearch) {
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->expects($this->once())
->method('buildGetUsersQuery')
->with($userToSearch)
->willReturn('SELECT * FROM Users');
return $queryBuilder;
});
$queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('query')
->with('SELECT * FROM Users')
->willReturn($queryIterator);
return $handler;
});
$user = $this->createMock(User::class);
$user->expects($this->once())
->method('getId')
->willReturn(123);
$this->mockTeamRepository($team, $salesforceUser, $user);
$profileRepository = $this->createMock(ProfileRepository::class);
$profileRepository->expects($this->once())
->method('updateOrCreateProfile')
->with(
$user,
[
'crm_configuration_id' => 1,
'crm_provider_id' => '456',
],
[
'user_id' => 123,
'edition' => Profile::EDITION_LIGHTNING,
'has_external_cti' => true,
'crm_profile_id' => '789',
]
)
->willReturn(new Profile());
$this->app->instance(ProfileRepository::class, $profileRepository);
$serviceMock = $this->getServiceMock();
$serviceMock->team = $team;
$serviceMock->config = $config;
$result = $serviceMock->syncProfiles($userToSearch);
$this->assertNull($result);
}
public function testSyncProfilesEmailIsNull(): void
{
$userToSearch = $this->createMock(User::class);
$salesforceUser = [
'Email' => null,
];
app()->bind(QueryBuilder::class, function () {
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->expects($this->once())
->method('buildGetUsersQuery')
->with(null)
->willReturn('SELECT * FROM Users');
return $queryBuilder;
});
$queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->exactly(2))
->method('query')
->willReturn($queryIterator);
return $handler;
});
$team = $this->createMock(Team::class);
$user = $this->createMock(User::class);
$this->mockTeamRepository($team, $salesforceUser, $user, false);
$config = $this->createMock(Configuration::class);
$serviceMock = $this->getServiceMock();
$serviceMock->team = $team;
$serviceMock->config = $config;
$profile = $serviceMock->syncProfiles(null);
$this->assertNull($profile);
}
public function testSyncProfilesUserToSearchMatchesCurrentUser(): void
{
$userToSearch = $this->createMock(User::class);
$userToSearch->expects($this->once())
->method('getId')
->willReturn(123);
$team = $this->createMock(Team::class);
$config = $this->createMock(Configuration::class);
$config->expects($this->once())
->method('getId')
->willReturn(1);
$salesforceUser = [
'Email' => '[EMAIL]',
'UserPreferencesLightningExperiencePreferred' => true,
'CallCenterId' => '123',
'Id' => '456',
'ProfileId' => '789',
];
app()->bind(QueryBuilder::class, function () use ($userToSearch) {
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->expects($this->once())
->method('buildGetUsersQuery')
->with($userToSearch)
->willReturn('SELECT * FROM Users');
return $queryBuilder;
});
$queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('query')
->with('SELECT * FROM Users')
->willReturn($queryIterator);
return $handler;
});
$user = $this->createMock(User::class);
$user->expects($this->exactly(2))
->method('getId')
->willReturn(123);
$this->mockTeamRepository($team, $salesforceUser, $user);
$profileRepository = $this->createMock(ProfileRepository::class);
$profileRepository->expects($this->once())
->method('updateOrCreateProfile')
->with(
$user,
[
'crm_configuration_id' => 1,
'crm_provider_id' => '456',
],
[
'user_id' => 123,
'edition' => Profile::EDITION_LIGHTNING,
'has_external_cti' => true,
'crm_profile_id' => '789',
]
)
->willReturn(new Profile());
$this->app->instance(ProfileRepository::class, $profileRepository);
$serviceMock = $this->getServiceMock();
$serviceMock->team = $team;
$serviceMock->config = $config;
$profile = $serviceMock->syncProfiles($userToSearch);
$this->assertInstanceOf(Profile::class, $profile);
}
public function testSyncProfilesWithCustomValidation(): void
{
$userToSearch = null;
$team = $this->createMock(Team::class);
$config = $this->createMockedConfiguration();
$config->expects($this->atLeastOnce()) // Changed from once() to atLeastOnce()
->method('getId')
->willReturn(1);
$salesforceUser = [
'Email' => '[EMAIL]',
'UserPreferencesLightningExperiencePreferred' => true,
'CallCenterId' => '123',
'Id' => '456',
'ProfileId' => '789',
'CustomField' => 'CustomValue',
];
$customRules = [
['field' => 'CustomField', 'value' => 'CustomValue'],
];
$this->mockQueryBuilderAndHandler($userToSearch, [$salesforceUser]);
$user = $this->createMock(User::class);
$user->expects($this->once())
->method('getId')
->willReturn(123);
$this->mockTeamRepository($team, $salesforceUser, $user, true, $customRules);
$this->mockProfileRepository($user);
$serviceMock = $this->getServiceMock();
$serviceMock->team = $team;
$serviceMock->config = $config;
$profile = $serviceMock->syncProfiles($userToSearch);
$this->assertNull($profile);
}
public function testSyncProfilesWithCustomValidationFailing(): void
{
$userToSearch = null;
$team = $this->createMock(Team::class);
$config = $this->createMockedConfiguration();
$salesforceUser = [
'Email' => '[EMAIL]',
'UserPreferencesLightningExperiencePreferred' => true,
'CallCenterId' => '123',
'Id' => '456',
'ProfileId' => '789',
'CustomField' => 'WrongValue',
];
$customRules = [
['field' => 'CustomField', 'value' => 'CustomValue'],
];
$this->mockQueryBuilderAndHandler($userToSearch, [$salesforceUser]);
$teamRepository = $this->getMockForAbstractClass(TeamRepository::class, [], '', false, true, true, ['findActiveTeamMemberByEmail', 'getTeamSetting']);
$teamSettings = $this->createMock(TeamSettings::class);
$teamSettings->method('getValueType')
->willReturn('array');
$teamSettings->method('getValue')
->willReturn(json_encode($customRules));
$teamRepository->expects($this->once())
->method('getTeamSetting')
->with($team, 'custom_profile_validation')
->willReturn($teamSettings);
app()->bind(TeamRepository::class, function () use ($teamRepository) {
return $teamRepository;
});
$profileRepository = $this->createMock(ProfileRepository::class);
$profileRepository->expects($this->never())
->method('updateOrCreateProfile');
$this->app->instance(ProfileRepository::class, $profileRepository);
$serviceMock = $this->getServiceMock();
$serviceMock->team = $team;
$serviceMock->config = $config;
$result = $serviceMock->syncProfiles($userToSearch);
$this->assertNull($result);
}
public function testGetContactRolesFromCrm(): void
{
$contactRoles = [
[
'Id' => '1',
'ContactId' => 'Contact1',
'OpportunityId' => 'Opportunity1',
'Opportunity' => ['OwnerId' => 'Owner1'],
'IsPrimary' => true,
'Role' => 'Decision Maker',
],
];
$expectedResponse = [
[
'id' => '1',
'contactId' => 'Contact1',
'opportunityId' => 'Opportunity1',
'ownerId' => 'Owner1',
'isPrimary' => true,
'role' => 'Decision Maker',
],
];
$this->bindQueryIterator($contactRoles);
$serviceMock = $this->getServiceMock();
$data = $serviceMock->getContactRolesFromCrm(now()->subDay());
$this->assertEquals($expectedResponse, $data);
}
public function testGetContactRolesFromCrmNoResult(): void
{
$this->bindQueryIterator([]);
$serviceMock = $this->getServiceMock();
$data = $serviceMock->getContactRolesFromCrm(now()->subDay());
$this->assertEquals([], $data);
}
public function testSyncContactRoles(): void
{
$contactRoles = [
[
'id' => '1',
'contactId' => 'Contact1',
'opportunityId' => 'Opportunity1',
'ownerId' => 'Owner1',
'isPrimary' => true,
'role' => 'Decision Maker',
],
];
app()->bind(ContactRoleRepository::class, function () {
$contactRoleRepository = $this->createMock(ContactRoleRepository::class);
$contactRoleRepository->expects($this->once())
->method('saveContactRoles');
return $contactRoleRepository;
});
$serviceMock = $this->getServiceMock([
'getContactRolesFromCrm',
'syncRemotelyDeletedContactRoles',
'syncContact',
'syncOpportunity',
]);
$config = $this->createMock(Configuration::class);
$hasMany = $this->createMock(HasManyExtended::class);
$hasMany->expects($this->exactly(2))
->method('where')
->willReturn($hasMany);
$hasMany->expects($this->exactly(2))
->method('first')
->willReturn(
$this->createMock(Contact::class),
$this->createMock(Opportunity::class)
);
$config->expects($this->once())
->method('contacts')
->willReturn($hasMany);
$config->expects($this->once())
->method('opportunities')
->willReturn($hasMany);
$serviceMock->config = $config;
$serviceMock->expects($this->once())
->method('getContactRolesFromCrm')
->willReturn($contactRoles);
$serviceMock->expects($this->once())
->method('syncRemotelyDeletedContactRoles');
$data = $serviceMock->syncContactRoles(now()->subDay());
$this->assertEquals(1, $data);
}
public function testSyncRemotelyDeletedContactRoles(): void
{
$contactRoles = [
[
'id' => '1',
'crm_provider_id' => '1',
],
];
app()->bind(QueryHandler::class, function () use ($contactRoles) {
$queryResults = new QueryResults($contactRoles, 1, true, null);
$handler = $this->createMock(QueryHandler::class);
$handler->method('queryDeleted')
->willReturn($queryResults);
return $handler;
});
app()->bind(ContactRoleRepository::class, function () {
$contactRoleRepository = $this->createMock(ContactRoleRepository::class);
$contactRoleRepository->expects($this->once())
->method('deleteContactRoles');
return $contactRoleRepository;
});
$serviceMock = $this->getServiceMock();
$serviceMock->team = $this->createMock(Team::class);
$data = $this->invokePrivateMethod('syncRemotelyDeletedContactRoles', $serviceMock, []);
$this->assertTrue($data);
}
private function bindQueryIterator(array $queryResult): void
{
/** @var Client $client */
$client = $this->createMock(Client::class);
$queryIterator = new QueryIterator(
$client,
new QueryResults($queryResult, 1, true, null)
);
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('query')
->willReturn($queryIterator);
return $handler;
});
}
public static function getOpportunitySortOrderDataProvider(): array
{
return [
'all open recently updated' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED, ['LastModifiedDate DESC', true]],
'all open recently created' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED, ['CreatedDate DESC', true]],
'all open oldest created' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED, ['CreatedDate ASC', true]],
'all recently updated' => [Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED, ['LastModifiedDate DESC', false]],
'default' => ['unknown', ['LastModifiedDate DESC', true]],
];
}
private function createMockedConfiguration(): Configuration
{
$config = $this->createMock(Configuration::class);
$profilesRelation = $this->getMockBuilder(\Illuminate\Database\Eloquent\Relations\HasMany::class)
->disableOriginalConstructor()
->onlyMethods(['get'])
->addMethods(['where', 'first'])
->getMock();
$profilesRelation->method('where')->willReturnSelf();
$profilesRelation->method('get')->willReturn(collect([]));
$profilesRelation->method('first')->willReturn(null);
$config->method('profiles')->willReturn($profilesRelation);
return $config;
}
private function getServiceMock(array $onlyMethods = []): MockObject&Service
{
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$this->createMock(Client::class),
$this->createMock(PayloadBuilder::class),
$this->createMock(Dispatcher::class),
$this->createMock(CountriesMap::class),
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods($onlyMethods)
->getMock();
$serviceMock->profile = $this->createMock(Profile::class);
return $serviceMock;
}
private function mockQueryBuilderAndHandler($userToSearch, $salesforceUsers): void
{
app()->bind(QueryBuilder::class, function () use ($userToSearch) {
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->expects($this->once())
->method('buildGetUsersQuery')
->with($userToSearch)
->willReturn('SELECT * FROM Users');
return $queryBuilder;
});
$queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults($salesforceUsers, count($salesforceUsers), true, null));
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('query')
->willReturn($queryIterator);
return $handler;
});
}
private function mockTeamRepository(
Team $team,
array $salesforceUser,
?User $user = null,
bool $userSearch = true,
array $customRules = []
): void {
$teamRepository = $this->getMockForAbstractClass(TeamRepository::class, [], '', false, true, true, ['findActiveTeamMemberByEmail', 'getTeamSetting']);
if ($userSearch) {
$teamRepository->expects($this->once())
->method('findActiveTeamMemberByEmail')
->with($team, $salesforceUser['Email'])
->willReturn($user);
}
$teamSettings = $this->createMock(TeamSettings::class);
$teamSettings->method('getValueType')
->willReturn('array');
$teamSettings->method('getValue')
->willReturn(json_encode($customRules));
$teamRepository->expects($this->once())
->method('getTeamSetting')
->with($team, 'custom_profile_validation')
->willReturn($teamSettings);
app()->bind(TeamRepository::class, function () use ($teamRepository) {
return $teamRepository;
});
}
private function mockProfileRepository(User $user): void
{
$profileRepository = $this->createMock(ProfileRepository::class);
$profileRepository->expects($this->once())
->method('updateOrCreateProfile')
->with(
$user,
[
'crm_configuration_id' => 1,
'crm_provider_id' => '456',
],
[
'user_id' => 123,
'edition' => Profile::EDITION_LIGHTNING,
'has_external_cti' => true,
'crm_profile_id' => '789',
]
)
->willReturn(new Profile());
$this->app->instance(ProfileRepository::class, $profileRepository);
}
public function testBuildEnhancedNoteDecodesHtmlEntities(): void
{
$service = $this->getServiceMock(['createRecord']);
$profile = new Profile();
$profile->setAttribute('crm_provider_id', 'owner-123');
$service->profile = $profile;
$service->expects($this->exactly(2))
->method('createRecord')
->willReturnOnConsecutiveCalls('note-id-123', 'link-id-456');
$bodyWithEntities = 'Welch's current challenges and Facebook's Club';
$result = $this->invokePrivateMethod('buildEnhancedNote', $service, [
'Test Title',
$bodyWithEntities,
'object-id-789',
]);
$this->assertEquals('note-id-123', $result);
}
public function testBuildEnhancedNoteSanitizesWithoutQuotes(): void
{
$service = $this->getServiceMock(['createRecord']);
$profile = new Profile();
$profile->setAttribute('crm_provider_id', 'owner-456');
$service->profile = $profile;
$service->expects($this->exactly(2))
->method('createRecord')
->willReturnCallback(function ($type, $data) {
if ($type === 'ContentNote') {
$decoded = base64_decode($data['Content']);
$this->assertStringContainsString("Welch's", $decoded);
$this->assertStringNotContainsString(''', $decoded);
$this->assertStringNotContainsString('&#039;', $decoded);
$this->assertStringContainsString('<script>', $decoded);
return 'note-id-456';
}
return 'link-id-789';
});
$bodyWithMixedContent = "Welch's and <script>alert('xss')</script>";
$result = $this->invokePrivateMethod('buildEnhancedNote', $service, [
'Test Title',
$bodyWithMixedContent,
'object-id-123',
]);
$this->assertEquals('note-id-456', $result);
}
public function testBuildEnhancedNoteConvertsLineBreaks(): void
{
$service = $this->getServiceMock(['createRecord']);
$profile = new Profile();
$profile->setAttribute('crm_provider_id', 'owner-789');
$service->profile = $profile;
$service->expects($this->exactly(2))
->method('createRecord')
->willReturnCallback(function ($type, $data) {
if ($type === 'ContentNote') {
$decoded = base64_decode($data['Content']);
$this->assertStringContainsString('<br>', $decoded);
$this->assertStringNotContainsString('<br />', $decoded);
return 'note-id-789';
}
return 'link-id-012';
});
$bodyWithLineBreaks = "Line 1\nLine 2\nLine 3";
$result = $this->invokePrivateMethod('buildEnhancedNote', $service, [
'Test Title',
$bodyWithLineBreaks,
'object-id-456',
]);
$this->assertEquals('note-id-789', $result);
}
public function testBuildEnhancedNoteHandlesComplexScenario(): void
{
$service = $this->getServiceMock(['createRecord']);
$profile = new Profile();
$profile->setAttribute('crm_provider_id', 'owner-complex');
$service->profile = $profile;
$service->expects($this->exactly(2))
->method('createRecord')
->willReturnCallback(function ($type, $data) {
if ($type === 'ContentNote') {
$decoded = base64_decode($data['Content']);
$this->assertStringContainsString("Welch's", $decoded);
$this->assertStringContainsString("Facebook's Club", $decoded);
$this->assertStringContainsString("Arctics'", $decoded);
$this->assertStringNotContainsString(''', $decoded);
$this->assertStringNotContainsString('&#039;', $decoded);
$this->assertStringContainsString('<br>', $decoded);
$this->assertStringContainsString('<', $decoded);
$this->assertStringContainsString('>', $decoded);
return 'note-complex';
}
return 'link-complex';
});
$complexBody = "Summary:\n---------\nThe call focused on understanding Welch's current challenges and exploring how Arctics' Revenue Growth Management solutions could support their strategic goals.\n\n• John SMith discussed his role as a category advisor for Google and Facebook's Club, emphasizing the importance of market research and advising on product assortment.\n• Madona introduced Arctics' Virtual Shoppers AI, which simulates consumer <behavior> to optimize pricing and promotional strategies.";
$result = $this->invokePrivateMethod('buildEnhancedNote', $service, [
'Jiminny Transcription Summary',
$complexBody,
'task-id-001',
]);
$this->assertEquals('note-complex', $result);
}
public function testSyncRemotelyDeletedObjectsWithErrorHandlingSuccess(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$prospectPhotoPathService,
])
->onlyMethods(['syncRemotelyDeletedObjects'])
->getMock();
// Mock team
$team = $this->createMock(Team::class);
$team->method('getUuid')->willReturn('team-uuid-123');
$serviceMock->team = $team;
// Expect syncRemotelyDeletedObjects to be called once and succeed
$serviceMock->expects($this->once())
->method('syncRemotelyDeletedObjects')
->with(\Jiminny\Enums\CrmObject::ACCOUNT);
// Call the protected method using reflection
$reflection = new \ReflectionClass($serviceMock);
$method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');
$method->setAccessible(true);
// Should not throw any exceptions
$method->invoke($serviceMock, \Jiminny\Enums\CrmObject::ACCOUNT);
$this->assertTrue(true); // Test completed successfully
}
public function testSyncRemotelyDeletedObjectsWithErrorHandlingFailure(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$prospectPhotoPathService,
])
->onlyMethods(['syncRemotelyDeletedObjects'])
->getMock();
// Mock team
$team = $this->createMock(Team::class);
$team->method('getUuid')->willReturn('team-uuid-456');
$serviceMock->team = $team;
// Mock logger to verify warning is logged
$logger = $this->createMock(\Psr\Log\LoggerInterface::class);
// Use reflection to set the protected logger property
$reflection = new \ReflectionClass($serviceMock);
$loggerProperty = $reflection->getProperty('logger');
$loggerProperty->setAccessible(true);
$loggerProperty->setValue($serviceMock, $logger);
$exception = new \Exception('Sync failed due to API error');
// Expect syncRemotelyDeletedObjects to throw an exception
$serviceMock->expects($this->once())
->method('syncRemotelyDeletedObjects')
->with(\Jiminny\Enums\CrmObject::CONTACT)
->willThrowException($exception);
// Expect warning to be logged with correct message and parameters
$logger->expects($this->once())
->method('warning')
->with(
'[Salesforce] Remotely deleted objects sync failed',
[
'objectType' => 'contact',
'teamId' => 'team-uuid-456',
'reason' => 'Sync failed due to API error',
]
);
// Call the protected method using reflection
$reflection = new \ReflectionClass($serviceMock);
$method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');
$method->setAccessible(true);
// Should not re-throw the exception, just log it
$method->invoke($serviceMock, \Jiminny\Enums\CrmObject::CONTACT);
$this->assertTrue(true); // Test completed successfully
}
public function testSyncRemotelyDeletedObjectsWithErrorHandlingWithLogParams(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$prospectPhotoPathService,
])
->onlyMethods(['syncRemotelyDeletedObjects'])
->getMock();
// Mock team
$team = $this->createMock(Team::class);
$team->method('getUuid')->willReturn('team-uuid-789');
$serviceMock->team = $team;
// Mock logger to verify warning is logged
$logger = $this->createMock(\Psr\Log\LoggerInterface::class);
// Use reflection to set the protected logger property
$loggerReflection = new \ReflectionClass($serviceMock);
$loggerProperty = $loggerReflection->getProperty('logger');
$loggerProperty->setAccessible(true);
$loggerProperty->setValue($serviceMock, $logger);
$exception = new \Exception('Network timeout');
// Expect syncRemotelyDeletedObjects to throw an exception
$serviceMock->expects($this->once())
->method('syncRemotelyDeletedObjects')
->with(\Jiminny\Enums\CrmObject::OPPORTUNITY)
->willThrowException($exception);
// Additional log parameters
$logParams = [
'syncType' => 'full',
'batchSize' => 100,
];
// Expect warning to be logged with merged parameters
$logger->expects($this->once())
->method('warning')
->with(
'[Salesforce] Remotely deleted objects sync failed',
[
'objectType' => 'opportunity',
'teamId' => 'team-uuid-789',
'reason' => 'Network timeout',
'syncType' => 'full',
'batchSize' => 100,
]
);
// Call the protected method using reflection
$reflection = new \ReflectionClass($serviceMock);
$method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');
...
|
70623
|
NULL
|
NULL
|
NULL
|
|
70624
|
2537
|
5
|
2026-05-22T10:24:05.602169+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779445445602_m2.jpg...
|
PhpStorm
|
faVsco.js – ServiceTest.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20963-fix-import-on-de Project: faVsco.js, menu
JY-20963-fix-import-on-deleted-entity, menu
Start Listening for PHP Debug Connections
ServiceTest
Rerun 'PHPUnit: ServiceTest'
Debug 'ServiceTest'
Stop 'ServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Built-in Preview
Chrome
Firefox
Safari
Sync Changes
Hide This Notification
Code changed:
Hide
4
32
168
1
28
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Tests\Unit\Services\Crm\Salesforce;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Events\Dispatcher;
use Illuminate\Support\Facades\Cache;
use Jiminny\Component\Country\CountriesMap;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Enums\CrmObject;
use Jiminny\Exceptions\InvalidArgumentException;
use Jiminny\Exceptions\NoResultsException;
use Jiminny\Integrations\PlaybookResolver;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Playbook;
use Jiminny\Models\Team;
use Jiminny\Models\TeamSettings;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\ContactRoleRepository;
use Jiminny\Repositories\Crm\FieldDataRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\Salesforce\Client;
use Jiminny\Services\Crm\Salesforce\PayloadBuilder;
use Jiminny\Services\Crm\Salesforce\QueryBuilder;
use Jiminny\Services\Crm\Salesforce\QueryHandler;
use Jiminny\Services\Crm\Salesforce\QueryIterator;
use Jiminny\Services\Crm\Salesforce\QueryResults;
use Jiminny\Services\Crm\Salesforce\Service;
use PHPUnit\Framework\MockObject\MockObject;
use Tests\TestCase;
use Tests\Unit\Traits\TestPrivateMethod;
class ServiceTest extends TestCase
{
use TestPrivateMethod;
public function testFetchAndAssociateRelatedActivity(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$payloadBuilder->method('addCustomLogicFieldsPayload')
->willReturnCallback(function ($activity, $payload) {
return $payload;
});
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods(['fetchRelatedActivity', 'getPlaybook', 'getPlaybookCategory', 'updateRecord'])
->getMock();
$serviceMock->expects($this->once())
->method('fetchRelatedActivity')
->willReturn([
'Id' => 'testId',
'Type' => null,
'OwnerId' => 'testerUser',
'Description' => 'Test description',
]);
$user = $this->createMock(User::class);
$team = $this->createMock(Team::class);
$user->method('getAttribute')->with('team')->willReturn($team);
$playbook = $this->createMock(Playbook::class);
$playbook->method('getActivityField')->willReturn(null);
$playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_EVENT);
$serviceMock->expects($this->once())
->method('getPlaybook')
->with($user)
->willReturn($playbook);
$serviceMock->expects($this->never())
->method('getPlaybookCategory');
$serviceMock->expects($this->never())
->method('updateRecord');
$fieldDataRepository = $this->createMock(FieldDataRepository::class);
$fieldDataRepository->method('getActivityFieldData')->willReturn(collect([]));
app()->instance(FieldDataRepository::class, $fieldDataRepository);
$config = $this->createMock(Configuration::class);
$profilesRelation = $this->getMockBuilder(\Illuminate\Database\Eloquent\Relations\HasMany::class)
->disableOriginalConstructor()
->onlyMethods(['get'])
->addMethods(['where'])
->getMock();
$profilesRelation->method('where')->willReturnSelf();
$profilesRelation->method('get')->willReturn(collect([]));
$config->method('profiles')->willReturn($profilesRelation);
$serviceMock->config = $config;
$serviceMock->profile = null;
$actualStartTime = \Carbon\Carbon::now();
$activity = $this->getMockBuilder(Activity::class)
->disableOriginalConstructor()
->onlyMethods(['update', 'hasProspect'])
->getMock();
$activity->method('update')->willReturn(true);
$activity->method('hasProspect')->willReturn(true);
$activity->type = Activity::TYPE_CONFERENCE;
$activity->provider = Activity::PROVIDER_TWILIO;
$activity->lead_id = 1;
$activity->user_id = 0;
$activity->id_string = 'test-activity-id';
$activity->user = $user;
$activity->actual_start_time = $actualStartTime;
$activity->uuid = 'c53d8320-f556-4cee-a2f8-5f232f454ca4';
app()->bind(PlaybookResolver::class, function () use ($user) {
$playbook = $this->createMock(Playbook::class);
$playbookResolver = $this->createMock(PlaybookResolver::class);
$playbookResolver->expects($this->once())
->method('resolvePlaybookByUser')
->with($user)
->willReturn($playbook);
return $playbookResolver;
});
$data = $serviceMock->fetchAndAssociateRelatedActivity($activity);
$this->assertInstanceOf(Activity::class, $data);
$this->assertEquals(Activity::TYPE_CONFERENCE, $data->getType());
$this->assertEquals($actualStartTime->getTimestamp(), $data->getActualStartTime()->getTimestamp());
}
public function testFetchAndAssociateRelatedActivitySkipsForTaskBasedPlaybook(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods(['fetchRelatedActivity', 'getPlaybook'])
->getMock();
$user = $this->createMock(User::class);
$playbook = $this->createMock(Playbook::class);
$playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);
$playbook->method('getId')->willReturn(123);
$serviceMock->expects($this->once())
->method('getPlaybook')
->with($user)
->willReturn($playbook);
$serviceMock->expects($this->never())
->method('fetchRelatedActivity');
$activity = $this->getMockBuilder(Activity::class)
->disableOriginalConstructor()
->onlyMethods(['hasProspect', 'getUuid'])
->getMock();
$activity->method('hasProspect')->willReturn(true);
$activity->method('getUuid')->willReturn('c53d8320-f556-4cee-a2f8-5f232f454ca4');
$activity->type = Activity::TYPE_CONFERENCE;
$activity->actual_start_time = \Carbon\Carbon::now();
$activity->user = $user;
$result = $serviceMock->fetchAndAssociateRelatedActivity($activity);
$this->assertNull($result);
}
public function testFetchAndAssociateRelatedActivityReturnsNullForNonConference(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$serviceMock = new Service(
client: $client,
payloadBuilder: $payloadBuilder,
eventDispatcher: $eventDispatcher,
countriesMap: $countriesMap,
prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)
);
$activity = $this->getMockBuilder(Activity::class)
->disableOriginalConstructor()
->getMock();
$activity->type = Activity::TYPE_SOFTPHONE;
$result = $serviceMock->fetchAndAssociateRelatedActivity($activity);
$this->assertNull($result);
}
public function testFetchAndAssociateRelatedActivityReturnsNullWhenNoStartTime(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$serviceMock = new Service(
client: $client,
payloadBuilder: $payloadBuilder,
eventDispatcher: $eventDispatcher,
countriesMap: $countriesMap,
prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)
);
$activity = $this->getMockBuilder(Activity::class)
->disableOriginalConstructor()
->getMock();
$activity->type = Activity::TYPE_CONFERENCE;
$activity->actual_start_time = null;
$activity->scheduled_start_time = null;
$result = $serviceMock->fetchAndAssociateRelatedActivity($activity);
$this->assertNull($result);
}
public function testFetchAndAssociateRelatedActivityReturnsNullWhenNoProspect(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods(['getPlaybook'])
->getMock();
$serviceMock->expects($this->never())
->method('getPlaybook');
$activity = $this->getMockBuilder(Activity::class)
->disableOriginalConstructor()
->onlyMethods(['hasProspect', 'getUuid'])
->getMock();
$activity->method('hasProspect')->willReturn(false);
$activity->method('getUuid')->willReturn('c53d8320-f556-4cee-a2f8-5f232f454ca4');
$activity->type = Activity::TYPE_CONFERENCE;
$activity->actual_start_time = \Carbon\Carbon::now();
$result = $serviceMock->fetchAndAssociateRelatedActivity($activity);
$this->assertNull($result);
}
public function testMatchExactlyByEmail(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods([])
->getMock();
$profile = new Profile();
$profile->setAttribute('id', bin2hex(random_bytes(8)));
$serviceMock->profile = $profile;
$team = $this->createMock(Team::class);
$serviceMock->team = $team;
$data = $serviceMock->matchExactlyByEmail(bin2hex(random_bytes(8)) . '[EMAIL]');
$this->assertEquals(null, $data);
}
public function testMatchDomainFromEmail(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$queryIterator = $this->createMock(QueryIterator::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$config = $this->createMock(Configuration::class);
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('search')
->willReturn($queryIterator);
return $handler;
});
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods(['convertCrmData'])
->getMock();
$profile = new Profile();
$profile->account_fields = 'Field1, Field2, Field3';
$serviceMock->profile = $profile;
$serviceMock->expects($this->once())
->method('convertCrmData')
->willReturn(['test']);
$this->app->bind(QueryBuilder::class, function () {
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->expects($this->once())
->method('buildMatchByDomainQuery')
->with('[EMAIL]')
->willReturn('FIND {[EMAIL]} IN ALL FIELDS RETURNING Account(Id)');
return $queryBuilder;
});
$team = $this->createMock(Team::class);
$serviceMock->team = $team;
$serviceMock->config = $config;
$data = $serviceMock->matchByDomain('[EMAIL]');
$this->assertEquals(['test'], $data);
}
public function testBuildTaskSearchFields(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$service = new Service(
client: $client,
payloadBuilder: $payloadBuilder,
eventDispatcher: $eventDispatcher,
countriesMap: $countriesMap,
prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)
);
$fields = $service->buildTaskSearchFields();
$expectedFields = ['Id', 'WhoId', 'WhatId', 'AccountId'];
$this->assertEquals($expectedFields, $fields);
}
public function testMapCrmObjects(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$service = new Service(
client: $client,
payloadBuilder: $payloadBuilder,
eventDispatcher: $eventDispatcher,
countriesMap: $countriesMap,
prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class),
);
$sampleTask = [
'WhoId' => '003sampleWhoId',
'AccountId' => 'sampleAccountId',
'WhatId' => 'sampleWhatId',
];
$activityData = $service->mapCrmObjects($sampleTask);
$expectedActivityData = [
'contact' => '003sampleWhoId',
'account' => 'sampleAccountId',
'opportunity' => 'sampleWhatId',
];
$this->assertEquals($expectedActivityData, $activityData);
}
public function testGetInstalledAppVersion(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$queryIterator = $this->createMock(QueryIterator::class);
$queryIterator->expects($this->any())
->method('current')->willReturn([
'SubscriberPackageVersion' => [
'MajorVersion' => '1',
'MinorVersion' => '0',
'PatchVersion' => '1',
'BuildNumber' => '0',
],
]);
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('metadata')
->willReturn($queryIterator);
return $handler;
});
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods(array_diff(get_class_methods(Service::class), ['getInstalledAppVersion']))
->getMock();
$version = $serviceMock->getInstalledAppVersion();
$this->assertEquals('1010', $version);
}
public function testSyncProfiles(): void
{
$userToSearch = null;
$team = $this->createMock(Team::class);
$config = $this->createMockedConfiguration();
$config->expects($this->once())
->method('getId')
->willReturn(1);
$salesforceUser = [
'Email' => '[EMAIL]',
'UserPreferencesLightningExperiencePreferred' => true,
'CallCenterId' => '123',
'Id' => '456',
'ProfileId' => '789',
];
app()->bind(QueryBuilder::class, function () use ($userToSearch) {
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->expects($this->once())
->method('buildGetUsersQuery')
->with($userToSearch)
->willReturn('SELECT * FROM Users');
return $queryBuilder;
});
$queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('query')
->with('SELECT * FROM Users')
->willReturn($queryIterator);
return $handler;
});
$user = $this->createMock(User::class);
$user->expects($this->once())
->method('getId')
->willReturn(123);
$this->mockTeamRepository($team, $salesforceUser, $user);
$profileRepository = $this->createMock(ProfileRepository::class);
$profileRepository->expects($this->once())
->method('updateOrCreateProfile')
->with(
$user,
[
'crm_configuration_id' => 1,
'crm_provider_id' => '456',
],
[
'user_id' => 123,
'edition' => Profile::EDITION_LIGHTNING,
'has_external_cti' => true,
'crm_profile_id' => '789',
]
)
->willReturn(new Profile());
$this->app->instance(ProfileRepository::class, $profileRepository);
$serviceMock = $this->getServiceMock();
$serviceMock->team = $team;
$serviceMock->config = $config;
$result = $serviceMock->syncProfiles($userToSearch);
$this->assertNull($result);
}
public function testSyncProfilesEmailIsNull(): void
{
$userToSearch = $this->createMock(User::class);
$salesforceUser = [
'Email' => null,
];
app()->bind(QueryBuilder::class, function () {
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->expects($this->once())
->method('buildGetUsersQuery')
->with(null)
->willReturn('SELECT * FROM Users');
return $queryBuilder;
});
$queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->exactly(2))
->method('query')
->willReturn($queryIterator);
return $handler;
});
$team = $this->createMock(Team::class);
$user = $this->createMock(User::class);
$this->mockTeamRepository($team, $salesforceUser, $user, false);
$config = $this->createMock(Configuration::class);
$serviceMock = $this->getServiceMock();
$serviceMock->team = $team;
$serviceMock->config = $config;
$profile = $serviceMock->syncProfiles(null);
$this->assertNull($profile);
}
public function testSyncProfilesUserToSearchMatchesCurrentUser(): void
{
$userToSearch = $this->createMock(User::class);
$userToSearch->expects($this->once())
->method('getId')
->willReturn(123);
$team = $this->createMock(Team::class);
$config = $this->createMock(Configuration::class);
$config->expects($this->once())
->method('getId')
->willReturn(1);
$salesforceUser = [
'Email' => '[EMAIL]',
'UserPreferencesLightningExperiencePreferred' => true,
'CallCenterId' => '123',
'Id' => '456',
'ProfileId' => '789',
];
app()->bind(QueryBuilder::class, function () use ($userToSearch) {
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->expects($this->once())
->method('buildGetUsersQuery')
->with($userToSearch)
->willReturn('SELECT * FROM Users');
return $queryBuilder;
});
$queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('query')
->with('SELECT * FROM Users')
->willReturn($queryIterator);
return $handler;
});
$user = $this->createMock(User::class);
$user->expects($this->exactly(2))
->method('getId')
->willReturn(123);
$this->mockTeamRepository($team, $salesforceUser, $user);
$profileRepository = $this->createMock(ProfileRepository::class);
$profileRepository->expects($this->once())
->method('updateOrCreateProfile')
->with(
$user,
[
'crm_configuration_id' => 1,
'crm_provider_id' => '456',
],
[
'user_id' => 123,
'edition' => Profile::EDITION_LIGHTNING,
'has_external_cti' => true,
'crm_profile_id' => '789',
]
)
->willReturn(new Profile());
$this->app->instance(ProfileRepository::class, $profileRepository);
$serviceMock = $this->getServiceMock();
$serviceMock->team = $team;
$serviceMock->config = $config;
$profile = $serviceMock->syncProfiles($userToSearch);
$this->assertInstanceOf(Profile::class, $profile);
}
public function testSyncProfilesWithCustomValidation(): void
{
$userToSearch = null;
$team = $this->createMock(Team::class);
$config = $this->createMockedConfiguration();
$config->expects($this->atLeastOnce()) // Changed from once() to atLeastOnce()
->method('getId')
->willReturn(1);
$salesforceUser = [
'Email' => '[EMAIL]',
'UserPreferencesLightningExperiencePreferred' => true,
'CallCenterId' => '123',
'Id' => '456',
'ProfileId' => '789',
'CustomField' => 'CustomValue',
];
$customRules = [
['field' => 'CustomField', 'value' => 'CustomValue'],
];
$this->mockQueryBuilderAndHandler($userToSearch, [$salesforceUser]);
$user = $this->createMock(User::class);
$user->expects($this->once())
->method('getId')
->willReturn(123);
$this->mockTeamRepository($team, $salesforceUser, $user, true, $customRules);
$this->mockProfileRepository($user);
$serviceMock = $this->getServiceMock();
$serviceMock->team = $team;
$serviceMock->config = $config;
$profile = $serviceMock->syncProfiles($userToSearch);
$this->assertNull($profile);
}
public function testSyncProfilesWithCustomValidationFailing(): void
{
$userToSearch = null;
$team = $this->createMock(Team::class);
$config = $this->createMockedConfiguration();
$salesforceUser = [
'Email' => '[EMAIL]',
'UserPreferencesLightningExperiencePreferred' => true,
'CallCenterId' => '123',
'Id' => '456',
'ProfileId' => '789',
'CustomField' => 'WrongValue',
];
$customRules = [
['field' => 'CustomField', 'value' => 'CustomValue'],
];
$this->mockQueryBuilderAndHandler($userToSearch, [$salesforceUser]);
$teamRepository = $this->getMockForAbstractClass(TeamRepository::class, [], '', false, true, true, ['findActiveTeamMemberByEmail', 'getTeamSetting']);
$teamSettings = $this->createMock(TeamSettings::class);
$teamSettings->method('getValueType')
->willReturn('array');
$teamSettings->method('getValue')
->willReturn(json_encode($customRules));
$teamRepository->expects($this->once())
->method('getTeamSetting')
->with($team, 'custom_profile_validation')
->willReturn($teamSettings);
app()->bind(TeamRepository::class, function () use ($teamRepository) {
return $teamRepository;
});
$profileRepository = $this->createMock(ProfileRepository::class);
$profileRepository->expects($this->never())
->method('updateOrCreateProfile');
$this->app->instance(ProfileRepository::class, $profileRepository);
$serviceMock = $this->getServiceMock();
$serviceMock->team = $team;
$serviceMock->config = $config;
$result = $serviceMock->syncProfiles($userToSearch);
$this->assertNull($result);
}
public function testGetContactRolesFromCrm(): void
{
$contactRoles = [
[
'Id' => '1',
'ContactId' => 'Contact1',
'OpportunityId' => 'Opportunity1',
'Opportunity' => ['OwnerId' => 'Owner1'],
'IsPrimary' => true,
'Role' => 'Decision Maker',
],
];
$expectedResponse = [
[
'id' => '1',
'contactId' => 'Contact1',
'opportunityId' => 'Opportunity1',
'ownerId' => 'Owner1',
'isPrimary' => true,
'role' => 'Decision Maker',
],
];
$this->bindQueryIterator($contactRoles);
$serviceMock = $this->getServiceMock();
$data = $serviceMock->getContactRolesFromCrm(now()->subDay());
$this->assertEquals($expectedResponse, $data);
}
public function testGetContactRolesFromCrmNoResult(): void
{
$this->bindQueryIterator([]);
$serviceMock = $this->getServiceMock();
$data = $serviceMock->getContactRolesFromCrm(now()->subDay());
$this->assertEquals([], $data);
}
public function testSyncContactRoles(): void
{
$contactRoles = [
[
'id' => '1',
'contactId' => 'Contact1',
'opportunityId' => 'Opportunity1',
'ownerId' => 'Owner1',
'isPrimary' => true,
'role' => 'Decision Maker',
],
];
app()->bind(ContactRoleRepository::class, function () {
$contactRoleRepository = $this->createMock(ContactRoleRepository::class);
$contactRoleRepository->expects($this->once())
->method('saveContactRoles');
return $contactRoleRepository;
});
$serviceMock = $this->getServiceMock([
'getContactRolesFromCrm',
'syncRemotelyDeletedContactRoles',
'syncContact',
'syncOpportunity',
]);
$config = $this->createMock(Configuration::class);
$hasMany = $this->createMock(HasManyExtended::class);
$hasMany->expects($this->exactly(2))
->method('where')
->willReturn($hasMany);
$hasMany->expects($this->exactly(2))
->method('first')
->willReturn(
$this->createMock(Contact::class),
$this->createMock(Opportunity::class)
);
$config->expects($this->once())
->method('contacts')
->willReturn($hasMany);
$config->expects($this->once())
->method('opportunities')
->willReturn($hasMany);
$serviceMock->config = $config;
$serviceMock->expects($this->once())
->method('getContactRolesFromCrm')
->willReturn($contactRoles);
$serviceMock->expects($this->once())
->method('syncRemotelyDeletedContactRoles');
$data = $serviceMock->syncContactRoles(now()->subDay());
$this->assertEquals(1, $data);
}
public function testSyncRemotelyDeletedContactRoles(): void
{
$contactRoles = [
[
'id' => '1',
'crm_provider_id' => '1',
],
];
app()->bind(QueryHandler::class, function () use ($contactRoles) {
$queryResults = new QueryResults($contactRoles, 1, true, null);
$handler = $this->createMock(QueryHandler::class);
$handler->method('queryDeleted')
->willReturn($queryResults);
return $handler;
});
app()->bind(ContactRoleRepository::class, function () {
$contactRoleRepository = $this->createMock(ContactRoleRepository::class);
$contactRoleRepository->expects($this->once())
->method('deleteContactRoles');
return $contactRoleRepository;
});
$serviceMock = $this->getServiceMock();
$serviceMock->team = $this->createMock(Team::class);
$data = $this->invokePrivateMethod('syncRemotelyDeletedContactRoles', $serviceMock, []);
$this->assertTrue($data);
}
private function bindQueryIterator(array $queryResult): void
{
/** @var Client $client */
$client = $this->createMock(Client::class);
$queryIterator = new QueryIterator(
$client,
new QueryResults($queryResult, 1, true, null)
);
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('query')
->willReturn($queryIterator);
return $handler;
});
}
public static function getOpportunitySortOrderDataProvider(): array
{
return [
'all open recently updated' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED, ['LastModifiedDate DESC', true]],
'all open recently created' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED, ['CreatedDate DESC', true]],
'all open oldest created' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED, ['CreatedDate ASC', true]],
'all recently updated' => [Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED, ['LastModifiedDate DESC', false]],
'default' => ['unknown', ['LastModifiedDate DESC', true]],
];
}
private function createMockedConfiguration(): Configuration
{
$config = $this->createMock(Configuration::class);
$profilesRelation = $this->getMockBuilder(\Illuminate\Database\Eloquent\Relations\HasMany::class)
->disableOriginalConstructor()
->onlyMethods(['get'])
->addMethods(['where', 'first'])
->getMock();
$profilesRelation->method('where')->willReturnSelf();
$profilesRelation->method('get')->willReturn(collect([]));
$profilesRelation->method('first')->willReturn(null);
$config->method('profiles')->willReturn($profilesRelation);
return $config;
}
private function getServiceMock(array $onlyMethods = []): MockObject&Service
{
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$this->createMock(Client::class),
$this->createMock(PayloadBuilder::class),
$this->createMock(Dispatcher::class),
$this->createMock(CountriesMap::class),
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods($onlyMethods)
->getMock();
$serviceMock->profile = $this->createMock(Profile::class);
return $serviceMock;
}
private function mockQueryBuilderAndHandler($userToSearch, $salesforceUsers): void
{
app()->bind(QueryBuilder::class, function () use ($userToSearch) {
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->expects($this->once())
->method('buildGetUsersQuery')
->with($userToSearch)
->willReturn('SELECT * FROM Users');
return $queryBuilder;
});
$queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults($salesforceUsers, count($salesforceUsers), true, null));
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('query')
->willReturn($queryIterator);
return $handler;
});
}
private function mockTeamRepository(
Team $team,
array $salesforceUser,
?User $user = null,
bool $userSearch = true,
array $customRules = []
): void {
$teamRepository = $this->getMockForAbstractClass(TeamRepository::class, [], '', false, true, true, ['findActiveTeamMemberByEmail', 'getTeamSetting']);
if ($userSearch) {
$teamRepository->expects($this->once())
->method('findActiveTeamMemberByEmail')
->with($team, $salesforceUser['Email'])
->willReturn($user);
}
$teamSettings = $this->createMock(TeamSettings::class);
$teamSettings->method('getValueType')
->willReturn('array');
$teamSettings->method('getValue')
->willReturn(json_encode($customRules));
$teamRepository->expects($this->once())
->method('getTeamSetting')
->with($team, 'custom_profile_validation')
->willReturn($teamSettings);
app()->bind(TeamRepository::class, function () use ($teamRepository) {
return $teamRepository;
});
}
private function mockProfileRepository(User $user): void
{
$profileRepository = $this->createMock(ProfileRepository::class);
$profileRepository->expects($this->once())
->method('updateOrCreateProfile')
->with(
$user,
[
'crm_configuration_id' => 1,
'crm_provider_id' => '456',
],
[
'user_id' => 123,
'edition' => Profile::EDITION_LIGHTNING,
'has_external_cti' => true,
'crm_profile_id' => '789',
]
)
->willReturn(new Profile());
$this->app->instance(ProfileRepository::class, $profileRepository);
}
public function testBuildEnhancedNoteDecodesHtmlEntities(): void
{
$service = $this->getServiceMock(['createRecord']);
$profile = new Profile();
$profile->setAttribute('crm_provider_id', 'owner-123');
$service->profile = $profile;
$service->expects($this->exactly(2))
->method('createRecord')
->willReturnOnConsecutiveCalls('note-id-123', 'link-id-456');
$bodyWithEntities = 'Welch's current challenges and Facebook's Club';
$result = $this->invokePrivateMethod('buildEnhancedNote', $service, [
'Test Title',
$bodyWithEntities,
'object-id-789',
]);
$this->assertEquals('note-id-123', $result);
}
public function testBuildEnhancedNoteSanitizesWithoutQuotes(): void
{
$service = $this->getServiceMock(['createRecord']);
$profile = new Profile();
$profile->setAttribute('crm_provider_id', 'owner-456');
$service->profile = $profile;
$service->expects($this->exactly(2))
->method('createRecord')
->willReturnCallback(function ($type, $data) {
if ($type === 'ContentNote') {
$decoded = base64_decode($data['Content']);
$this->assertStringContainsString("Welch's", $decoded);
$this->assertStringNotContainsString(''', $decoded);
$this->assertStringNotContainsString('&#039;', $decoded);
$this->assertStringContainsString('<script>', $decoded);
return 'note-id-456';
}
return 'link-id-789';
});
$bodyWithMixedContent = "Welch's and <script>alert('xss')</script>";
$result = $this->invokePrivateMethod('buildEnhancedNote', $service, [
'Test Title',
$bodyWithMixedContent,
'object-id-123',
]);
$this->assertEquals('note-id-456', $result);
}
public function testBuildEnhancedNoteConvertsLineBreaks(): void
{
$service = $this->getServiceMock(['createRecord']);
$profile = new Profile();
$profile->setAttribute('crm_provider_id', 'owner-789');
$service->profile = $profile;
$service->expects($this->exactly(2))
->method('createRecord')
->willReturnCallback(function ($type, $data) {
if ($type === 'ContentNote') {
$decoded = base64_decode($data['Content']);
$this->assertStringContainsString('<br>', $decoded);
$this->assertStringNotContainsString('<br />', $decoded);
return 'note-id-789';
}
return 'link-id-012';
});
$bodyWithLineBreaks = "Line 1\nLine 2\nLine 3";
$result = $this->invokePrivateMethod('buildEnhancedNote', $service, [
'Test Title',
$bodyWithLineBreaks,
'object-id-456',
]);
$this->assertEquals('note-id-789', $result);
}
public function testBuildEnhancedNoteHandlesComplexScenario(): void
{
$service = $this->getServiceMock(['createRecord']);
$profile = new Profile();
$profile->setAttribute('crm_provider_id', 'owner-complex');
$service->profile = $profile;
$service->expects($this->exactly(2))
->method('createRecord')
->willReturnCallback(function ($type, $data) {
if ($type === 'ContentNote') {
$decoded = base64_decode($data['Content']);
$this->assertStringContainsString("Welch's", $decoded);
$this->assertStringContainsString("Facebook's Club", $decoded);
$this->assertStringContainsString("Arctics'", $decoded);
$this->assertStringNotContainsString(''', $decoded);
$this->assertStringNotContainsString('&#039;', $decoded);
$this->assertStringContainsString('<br>', $decoded);
$this->assertStringContainsString('<', $decoded);
$this->assertStringContainsString('>', $decoded);
return 'note-complex';
}
return 'link-complex';
});
$complexBody = "Summary:\n---------\nThe call focused on understanding Welch's current challenges and exploring how Arctics' Revenue Growth Management solutions could support their strategic goals.\n\n• John SMith discussed his role as a category advisor for Google and Facebook's Club, emphasizing the importance of market research and advising on product assortment.\n• Madona introduced Arctics' Virtual Shoppers AI, which simulates consumer <behavior> to optimize pricing and promotional strategies.";
$result = $this->invokePrivateMethod('buildEnhancedNote', $service, [
'Jiminny Transcription Summary',
$complexBody,
'task-id-001',
]);
$this->assertEquals('note-complex', $result);
}
public function testSyncRemotelyDeletedObjectsWithErrorHandlingSuccess(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$prospectPhotoPathService,
])
->onlyMethods(['syncRemotelyDeletedObjects'])
->getMock();
// Mock team
$team = $this->createMock(Team::class);
$team->method('getUuid')->willReturn('team-uuid-123');
$serviceMock->team = $team;
// Expect syncRemotelyDeletedObjects to be called once and succeed
$serviceMock->expects($this->once())
->method('syncRemotelyDeletedObjects')
->with(\Jiminny\Enums\CrmObject::ACCOUNT);
// Call the protected method using reflection
$reflection = new \ReflectionClass($serviceMock);
$method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');
$method->setAccessible(true);
// Should not throw any exceptions
$method->invoke($serviceMock, \Jiminny\Enums\CrmObject::ACCOUNT);
$this->assertTrue(true); // Test completed successfully
}
public function testSyncRemotelyDeletedObjectsWithErrorHandlingFailure(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$prospectPhotoPathService,
])
->onlyMethods(['syncRemotelyDeletedObjects'])
->getMock();
// Mock team
$team = $this->createMock(Team::class);
$team->method('getUuid')->willReturn('team-uuid-456');
$serviceMock->team = $team;
// Mock logger to verify warning is logged
$logger = $this->createMock(\Psr\Log\LoggerInterface::class);
// Use reflection to set the protected logger property
$reflection = new \ReflectionClass($serviceMock);
$loggerProperty = $reflection->getProperty('logger');
$loggerProperty->setAccessible(true);
$loggerProperty->setValue($serviceMock, $logger);
$exception = new \Exception('Sync failed due to API error');
// Expect syncRemotelyDeletedObjects to throw an exception
$serviceMock->expects($this->once())
->method('syncRemotelyDeletedObjects')
->with(\Jiminny\Enums\CrmObject::CONTACT)
->willThrowException($exception);
// Expect warning to be logged with correct message and parameters
$logger->expects($this->once())
->method('warning')
->with(
'[Salesforce] Remotely deleted objects sync failed',
[
'objectType' => 'contact',
'teamId' => 'team-uuid-456',
'reason' => 'Sync failed due to API error',
]
);
// Call the protected method using reflection
$reflection = new \ReflectionClass($serviceMock);
$method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');
$method->setAccessible(true);
// Should not re-throw the exception, just log it
$method->invoke($serviceMock, \Jiminny\Enums\CrmObject::CONTACT);
$this->assertTrue(true); // Test completed successfully
}
public function testSyncRemotelyDeletedObjectsWithErrorHandlingWithLogParams(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$prospectPhotoPathService,
])
->onlyMethods(['syncRemotelyDeletedObjects'])
->getMock();
// Mock team
$team = $this->createMock(Team::class);
$team->method('getUuid')->willReturn('team-uuid-789');
$serviceMock->team = $team;
// Mock logger to verify warning is logged
$logger = $this->createMock(\Psr\Log\LoggerInterface::class);
// Use reflection to set the protected logger property
$loggerReflection = new \ReflectionClass($serviceMock);
$loggerProperty = $loggerReflection->getProperty('logger');
$loggerProperty->setAccessible(true);
$loggerProperty->setValue($serviceMock, $logger);
$exception = new \Exception('Network timeout');
// Expect syncRemotelyDeletedObjects to throw an exception
$serviceMock->expects($this->once())
->method('syncRemotelyDeletedObjects')
->with(\Jiminny\Enums\CrmObject::OPPORTUNITY)
->willThrowException($exception);
// Additional log parameters
$logParams = [
'syncType' => 'full',
'batchSize' => 100,
];
// Expect warning to be logged with merged parameters
$logger->expects($this->once())
->method('warning')
->with(
'[Salesforce] Remotely deleted objects sync failed',
[
'objectType' => 'opportunity',
'teamId' => 'team-uuid-789',
'reason' => 'Network timeout',
'syncType' => 'full',
'batchSize' => 100,
]
);
// Call the protected method using reflection
$reflection = new \ReflectionClass($serviceMock);
$method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');
...
|
[{"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-20963-fix-import-on-deleted-entity, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.09607713,"height":0.025538707},"on_screen":true,"help_text":"Git Branch: JY-20963-fix-import-on-deleted-entity","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.85638297,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"ServiceTest","depth":6,"bounds":{"left":0.87167555,"top":0.019952115,"width":0.043882977,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Rerun 'PHPUnit: ServiceTest'","depth":6,"bounds":{"left":0.9155585,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'ServiceTest'","depth":6,"bounds":{"left":0.9268617,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Stop 'ServiceTest'","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":"More Actions","depth":6,"bounds":{"left":0.9494681,"top":0.019952115,"width":0.011303191,"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":"Built-in Preview","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":"Chrome","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":"Firefox","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":"Safari","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":"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":"4","depth":4,"bounds":{"left":0.35472074,"top":0.10055866,"width":0.007978723,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"32","depth":4,"bounds":{"left":0.36469415,"top":0.10055866,"width":0.010305851,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"168","depth":4,"bounds":{"left":0.37699467,"top":0.10055866,"width":0.011968086,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"bounds":{"left":0.39095744,"top":0.10055866,"width":0.00731383,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"28","depth":4,"bounds":{"left":0.40026596,"top":0.10055866,"width":0.009973404,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.4119016,"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.4192154,"top":0.09896249,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Services\\Crm\\Salesforce;\n\nuse Illuminate\\Database\\Eloquent\\Relations\\HasMany;\nuse Illuminate\\Events\\Dispatcher;\nuse Illuminate\\Support\\Facades\\Cache;\nuse Jiminny\\Component\\Country\\CountriesMap;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Enums\\CrmObject;\nuse Jiminny\\Exceptions\\InvalidArgumentException;\nuse Jiminny\\Exceptions\\NoResultsException;\nuse Jiminny\\Integrations\\PlaybookResolver;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Crm\\Configuration;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\TeamSettings;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\ContactRoleRepository;\nuse Jiminny\\Repositories\\Crm\\FieldDataRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\Salesforce\\Client;\nuse Jiminny\\Services\\Crm\\Salesforce\\PayloadBuilder;\nuse Jiminny\\Services\\Crm\\Salesforce\\QueryBuilder;\nuse Jiminny\\Services\\Crm\\Salesforce\\QueryHandler;\nuse Jiminny\\Services\\Crm\\Salesforce\\QueryIterator;\nuse Jiminny\\Services\\Crm\\Salesforce\\QueryResults;\nuse Jiminny\\Services\\Crm\\Salesforce\\Service;\nuse PHPUnit\\Framework\\MockObject\\MockObject;\nuse Tests\\TestCase;\nuse Tests\\Unit\\Traits\\TestPrivateMethod;\n\nclass ServiceTest extends TestCase\n{\n use TestPrivateMethod;\n\n public function testFetchAndAssociateRelatedActivity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $payloadBuilder->method('addCustomLogicFieldsPayload')\n ->willReturnCallback(function ($activity, $payload) {\n return $payload;\n });\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods(['fetchRelatedActivity', 'getPlaybook', 'getPlaybookCategory', 'updateRecord'])\n ->getMock();\n\n $serviceMock->expects($this->once())\n ->method('fetchRelatedActivity')\n ->willReturn([\n 'Id' => 'testId',\n 'Type' => null,\n 'OwnerId' => 'testerUser',\n 'Description' => 'Test description',\n ]);\n\n $user = $this->createMock(User::class);\n $team = $this->createMock(Team::class);\n $user->method('getAttribute')->with('team')->willReturn($team);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityField')->willReturn(null);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_EVENT);\n\n $serviceMock->expects($this->once())\n ->method('getPlaybook')\n ->with($user)\n ->willReturn($playbook);\n\n $serviceMock->expects($this->never())\n ->method('getPlaybookCategory');\n\n $serviceMock->expects($this->never())\n ->method('updateRecord');\n\n $fieldDataRepository = $this->createMock(FieldDataRepository::class);\n $fieldDataRepository->method('getActivityFieldData')->willReturn(collect([]));\n app()->instance(FieldDataRepository::class, $fieldDataRepository);\n\n $config = $this->createMock(Configuration::class);\n $profilesRelation = $this->getMockBuilder(\\Illuminate\\Database\\Eloquent\\Relations\\HasMany::class)\n ->disableOriginalConstructor()\n ->onlyMethods(['get'])\n ->addMethods(['where'])\n ->getMock();\n $profilesRelation->method('where')->willReturnSelf();\n $profilesRelation->method('get')->willReturn(collect([]));\n $config->method('profiles')->willReturn($profilesRelation);\n\n $serviceMock->config = $config;\n $serviceMock->profile = null;\n\n $actualStartTime = \\Carbon\\Carbon::now();\n\n $activity = $this->getMockBuilder(Activity::class)\n ->disableOriginalConstructor()\n ->onlyMethods(['update', 'hasProspect'])\n ->getMock();\n\n $activity->method('update')->willReturn(true);\n $activity->method('hasProspect')->willReturn(true);\n\n $activity->type = Activity::TYPE_CONFERENCE;\n $activity->provider = Activity::PROVIDER_TWILIO;\n $activity->lead_id = 1;\n $activity->user_id = 0;\n $activity->id_string = 'test-activity-id';\n $activity->user = $user;\n\n $activity->actual_start_time = $actualStartTime;\n $activity->uuid = 'c53d8320-f556-4cee-a2f8-5f232f454ca4';\n\n app()->bind(PlaybookResolver::class, function () use ($user) {\n $playbook = $this->createMock(Playbook::class);\n $playbookResolver = $this->createMock(PlaybookResolver::class);\n $playbookResolver->expects($this->once())\n ->method('resolvePlaybookByUser')\n ->with($user)\n ->willReturn($playbook);\n\n return $playbookResolver;\n });\n\n $data = $serviceMock->fetchAndAssociateRelatedActivity($activity);\n\n $this->assertInstanceOf(Activity::class, $data);\n $this->assertEquals(Activity::TYPE_CONFERENCE, $data->getType());\n $this->assertEquals($actualStartTime->getTimestamp(), $data->getActualStartTime()->getTimestamp());\n }\n\n public function testFetchAndAssociateRelatedActivitySkipsForTaskBasedPlaybook(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods(['fetchRelatedActivity', 'getPlaybook'])\n ->getMock();\n\n $user = $this->createMock(User::class);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n $playbook->method('getId')->willReturn(123);\n\n $serviceMock->expects($this->once())\n ->method('getPlaybook')\n ->with($user)\n ->willReturn($playbook);\n\n $serviceMock->expects($this->never())\n ->method('fetchRelatedActivity');\n\n $activity = $this->getMockBuilder(Activity::class)\n ->disableOriginalConstructor()\n ->onlyMethods(['hasProspect', 'getUuid'])\n ->getMock();\n\n $activity->method('hasProspect')->willReturn(true);\n $activity->method('getUuid')->willReturn('c53d8320-f556-4cee-a2f8-5f232f454ca4');\n $activity->type = Activity::TYPE_CONFERENCE;\n $activity->actual_start_time = \\Carbon\\Carbon::now();\n $activity->user = $user;\n\n $result = $serviceMock->fetchAndAssociateRelatedActivity($activity);\n\n $this->assertNull($result);\n }\n\n public function testFetchAndAssociateRelatedActivityReturnsNullForNonConference(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $serviceMock = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)\n );\n\n $activity = $this->getMockBuilder(Activity::class)\n ->disableOriginalConstructor()\n ->getMock();\n\n $activity->type = Activity::TYPE_SOFTPHONE;\n\n $result = $serviceMock->fetchAndAssociateRelatedActivity($activity);\n\n $this->assertNull($result);\n }\n\n public function testFetchAndAssociateRelatedActivityReturnsNullWhenNoStartTime(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $serviceMock = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)\n );\n\n\n $activity = $this->getMockBuilder(Activity::class)\n ->disableOriginalConstructor()\n ->getMock();\n\n $activity->type = Activity::TYPE_CONFERENCE;\n $activity->actual_start_time = null;\n $activity->scheduled_start_time = null;\n\n $result = $serviceMock->fetchAndAssociateRelatedActivity($activity);\n\n $this->assertNull($result);\n }\n\n public function testFetchAndAssociateRelatedActivityReturnsNullWhenNoProspect(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods(['getPlaybook'])\n ->getMock();\n\n $serviceMock->expects($this->never())\n ->method('getPlaybook');\n\n $activity = $this->getMockBuilder(Activity::class)\n ->disableOriginalConstructor()\n ->onlyMethods(['hasProspect', 'getUuid'])\n ->getMock();\n\n $activity->method('hasProspect')->willReturn(false);\n $activity->method('getUuid')->willReturn('c53d8320-f556-4cee-a2f8-5f232f454ca4');\n $activity->type = Activity::TYPE_CONFERENCE;\n $activity->actual_start_time = \\Carbon\\Carbon::now();\n\n $result = $serviceMock->fetchAndAssociateRelatedActivity($activity);\n\n $this->assertNull($result);\n }\n\n public function testMatchExactlyByEmail(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods([])\n ->getMock();\n\n $profile = new Profile();\n $profile->setAttribute('id', bin2hex(random_bytes(8)));\n $serviceMock->profile = $profile;\n\n $team = $this->createMock(Team::class);\n $serviceMock->team = $team;\n\n $data = $serviceMock->matchExactlyByEmail(bin2hex(random_bytes(8)) . 'test_email@testserver.com');\n\n $this->assertEquals(null, $data);\n }\n\n public function testMatchDomainFromEmail(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $queryIterator = $this->createMock(QueryIterator::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $config = $this->createMock(Configuration::class);\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('search')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods(['convertCrmData'])\n ->getMock();\n\n $profile = new Profile();\n $profile->account_fields = 'Field1, Field2, Field3';\n $serviceMock->profile = $profile;\n\n $serviceMock->expects($this->once())\n ->method('convertCrmData')\n ->willReturn(['test']);\n\n $this->app->bind(QueryBuilder::class, function () {\n $queryBuilder = $this->createMock(QueryBuilder::class);\n $queryBuilder->expects($this->once())\n ->method('buildMatchByDomainQuery')\n ->with('test_email@testserver.com')\n ->willReturn('FIND {test_email@testserver.com} IN ALL FIELDS RETURNING Account(Id)');\n\n return $queryBuilder;\n });\n\n $team = $this->createMock(Team::class);\n\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n\n $data = $serviceMock->matchByDomain('test_email@testserver.com');\n\n $this->assertEquals(['test'], $data);\n }\n\n public function testBuildTaskSearchFields(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)\n );\n\n $fields = $service->buildTaskSearchFields();\n\n $expectedFields = ['Id', 'WhoId', 'WhatId', 'AccountId'];\n\n $this->assertEquals($expectedFields, $fields);\n }\n\n public function testMapCrmObjects(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class),\n );\n\n $sampleTask = [\n 'WhoId' => '003sampleWhoId',\n 'AccountId' => 'sampleAccountId',\n 'WhatId' => 'sampleWhatId',\n ];\n\n $activityData = $service->mapCrmObjects($sampleTask);\n\n $expectedActivityData = [\n 'contact' => '003sampleWhoId',\n 'account' => 'sampleAccountId',\n 'opportunity' => 'sampleWhatId',\n ];\n\n $this->assertEquals($expectedActivityData, $activityData);\n }\n\n public function testGetInstalledAppVersion(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n $queryIterator = $this->createMock(QueryIterator::class);\n $queryIterator->expects($this->any())\n ->method('current')->willReturn([\n 'SubscriberPackageVersion' => [\n 'MajorVersion' => '1',\n 'MinorVersion' => '0',\n 'PatchVersion' => '1',\n 'BuildNumber' => '0',\n ],\n ]);\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('metadata')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods(array_diff(get_class_methods(Service::class), ['getInstalledAppVersion']))\n ->getMock();\n\n $version = $serviceMock->getInstalledAppVersion();\n\n $this->assertEquals('1010', $version);\n }\n\n public function testSyncProfiles(): void\n {\n $userToSearch = null;\n\n $team = $this->createMock(Team::class);\n $config = $this->createMockedConfiguration();\n $config->expects($this->once())\n ->method('getId')\n ->willReturn(1);\n\n $salesforceUser = [\n 'Email' => 'test@example.com',\n 'UserPreferencesLightningExperiencePreferred' => true,\n 'CallCenterId' => '123',\n 'Id' => '456',\n 'ProfileId' => '789',\n ];\n\n app()->bind(QueryBuilder::class, function () use ($userToSearch) {\n $queryBuilder = $this->createMock(QueryBuilder::class);\n $queryBuilder->expects($this->once())\n ->method('buildGetUsersQuery')\n ->with($userToSearch)\n ->willReturn('SELECT * FROM Users');\n\n return $queryBuilder;\n });\n\n $queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('query')\n ->with('SELECT * FROM Users')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n\n $user = $this->createMock(User::class);\n $user->expects($this->once())\n ->method('getId')\n ->willReturn(123);\n\n $this->mockTeamRepository($team, $salesforceUser, $user);\n\n $profileRepository = $this->createMock(ProfileRepository::class);\n $profileRepository->expects($this->once())\n ->method('updateOrCreateProfile')\n ->with(\n $user,\n [\n 'crm_configuration_id' => 1,\n 'crm_provider_id' => '456',\n ],\n [\n 'user_id' => 123,\n 'edition' => Profile::EDITION_LIGHTNING,\n 'has_external_cti' => true,\n 'crm_profile_id' => '789',\n ]\n )\n ->willReturn(new Profile());\n\n $this->app->instance(ProfileRepository::class, $profileRepository);\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n $result = $serviceMock->syncProfiles($userToSearch);\n\n $this->assertNull($result);\n }\n\n public function testSyncProfilesEmailIsNull(): void\n {\n $userToSearch = $this->createMock(User::class);\n\n $salesforceUser = [\n 'Email' => null,\n ];\n\n app()->bind(QueryBuilder::class, function () {\n $queryBuilder = $this->createMock(QueryBuilder::class);\n $queryBuilder->expects($this->once())\n ->method('buildGetUsersQuery')\n ->with(null)\n ->willReturn('SELECT * FROM Users');\n\n return $queryBuilder;\n });\n\n $queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->exactly(2))\n ->method('query')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n\n $team = $this->createMock(Team::class);\n $user = $this->createMock(User::class);\n $this->mockTeamRepository($team, $salesforceUser, $user, false);\n\n $config = $this->createMock(Configuration::class);\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n\n $profile = $serviceMock->syncProfiles(null);\n\n $this->assertNull($profile);\n }\n\n public function testSyncProfilesUserToSearchMatchesCurrentUser(): void\n {\n $userToSearch = $this->createMock(User::class);\n $userToSearch->expects($this->once())\n ->method('getId')\n ->willReturn(123);\n\n $team = $this->createMock(Team::class);\n $config = $this->createMock(Configuration::class);\n $config->expects($this->once())\n ->method('getId')\n ->willReturn(1);\n\n $salesforceUser = [\n 'Email' => 'test@example.com',\n 'UserPreferencesLightningExperiencePreferred' => true,\n 'CallCenterId' => '123',\n 'Id' => '456',\n 'ProfileId' => '789',\n ];\n\n app()->bind(QueryBuilder::class, function () use ($userToSearch) {\n $queryBuilder = $this->createMock(QueryBuilder::class);\n $queryBuilder->expects($this->once())\n ->method('buildGetUsersQuery')\n ->with($userToSearch)\n ->willReturn('SELECT * FROM Users');\n\n return $queryBuilder;\n });\n\n $queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('query')\n ->with('SELECT * FROM Users')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n\n $user = $this->createMock(User::class);\n $user->expects($this->exactly(2))\n ->method('getId')\n ->willReturn(123);\n\n $this->mockTeamRepository($team, $salesforceUser, $user);\n\n $profileRepository = $this->createMock(ProfileRepository::class);\n $profileRepository->expects($this->once())\n ->method('updateOrCreateProfile')\n ->with(\n $user,\n [\n 'crm_configuration_id' => 1,\n 'crm_provider_id' => '456',\n ],\n [\n 'user_id' => 123,\n 'edition' => Profile::EDITION_LIGHTNING,\n 'has_external_cti' => true,\n 'crm_profile_id' => '789',\n ]\n )\n ->willReturn(new Profile());\n\n $this->app->instance(ProfileRepository::class, $profileRepository);\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n $profile = $serviceMock->syncProfiles($userToSearch);\n\n $this->assertInstanceOf(Profile::class, $profile);\n }\n\n public function testSyncProfilesWithCustomValidation(): void\n {\n $userToSearch = null;\n\n $team = $this->createMock(Team::class);\n $config = $this->createMockedConfiguration();\n $config->expects($this->atLeastOnce()) // Changed from once() to atLeastOnce()\n ->method('getId')\n ->willReturn(1);\n\n $salesforceUser = [\n 'Email' => 'test@example.com',\n 'UserPreferencesLightningExperiencePreferred' => true,\n 'CallCenterId' => '123',\n 'Id' => '456',\n 'ProfileId' => '789',\n 'CustomField' => 'CustomValue',\n ];\n\n $customRules = [\n ['field' => 'CustomField', 'value' => 'CustomValue'],\n ];\n\n $this->mockQueryBuilderAndHandler($userToSearch, [$salesforceUser]);\n\n $user = $this->createMock(User::class);\n $user->expects($this->once())\n ->method('getId')\n ->willReturn(123);\n\n $this->mockTeamRepository($team, $salesforceUser, $user, true, $customRules);\n\n $this->mockProfileRepository($user);\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n $profile = $serviceMock->syncProfiles($userToSearch);\n\n $this->assertNull($profile);\n }\n\n public function testSyncProfilesWithCustomValidationFailing(): void\n {\n $userToSearch = null;\n\n $team = $this->createMock(Team::class);\n $config = $this->createMockedConfiguration();\n\n $salesforceUser = [\n 'Email' => 'test@example.com',\n 'UserPreferencesLightningExperiencePreferred' => true,\n 'CallCenterId' => '123',\n 'Id' => '456',\n 'ProfileId' => '789',\n 'CustomField' => 'WrongValue',\n ];\n\n $customRules = [\n ['field' => 'CustomField', 'value' => 'CustomValue'],\n ];\n\n $this->mockQueryBuilderAndHandler($userToSearch, [$salesforceUser]);\n\n $teamRepository = $this->getMockForAbstractClass(TeamRepository::class, [], '', false, true, true, ['findActiveTeamMemberByEmail', 'getTeamSetting']);\n\n $teamSettings = $this->createMock(TeamSettings::class);\n $teamSettings->method('getValueType')\n ->willReturn('array');\n\n $teamSettings->method('getValue')\n ->willReturn(json_encode($customRules));\n\n $teamRepository->expects($this->once())\n ->method('getTeamSetting')\n ->with($team, 'custom_profile_validation')\n ->willReturn($teamSettings);\n\n app()->bind(TeamRepository::class, function () use ($teamRepository) {\n return $teamRepository;\n });\n\n $profileRepository = $this->createMock(ProfileRepository::class);\n $profileRepository->expects($this->never())\n ->method('updateOrCreateProfile');\n\n $this->app->instance(ProfileRepository::class, $profileRepository);\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n $result = $serviceMock->syncProfiles($userToSearch);\n\n $this->assertNull($result);\n }\n\n public function testGetContactRolesFromCrm(): void\n {\n $contactRoles = [\n [\n 'Id' => '1',\n 'ContactId' => 'Contact1',\n 'OpportunityId' => 'Opportunity1',\n 'Opportunity' => ['OwnerId' => 'Owner1'],\n 'IsPrimary' => true,\n 'Role' => 'Decision Maker',\n ],\n ];\n\n $expectedResponse = [\n [\n 'id' => '1',\n 'contactId' => 'Contact1',\n 'opportunityId' => 'Opportunity1',\n 'ownerId' => 'Owner1',\n 'isPrimary' => true,\n 'role' => 'Decision Maker',\n ],\n ];\n\n $this->bindQueryIterator($contactRoles);\n\n $serviceMock = $this->getServiceMock();\n\n $data = $serviceMock->getContactRolesFromCrm(now()->subDay());\n\n $this->assertEquals($expectedResponse, $data);\n }\n\n public function testGetContactRolesFromCrmNoResult(): void\n {\n $this->bindQueryIterator([]);\n\n $serviceMock = $this->getServiceMock();\n\n $data = $serviceMock->getContactRolesFromCrm(now()->subDay());\n\n $this->assertEquals([], $data);\n }\n\n public function testSyncContactRoles(): void\n {\n $contactRoles = [\n [\n 'id' => '1',\n 'contactId' => 'Contact1',\n 'opportunityId' => 'Opportunity1',\n 'ownerId' => 'Owner1',\n 'isPrimary' => true,\n 'role' => 'Decision Maker',\n ],\n ];\n\n app()->bind(ContactRoleRepository::class, function () {\n $contactRoleRepository = $this->createMock(ContactRoleRepository::class);\n $contactRoleRepository->expects($this->once())\n ->method('saveContactRoles');\n\n return $contactRoleRepository;\n });\n\n $serviceMock = $this->getServiceMock([\n 'getContactRolesFromCrm',\n 'syncRemotelyDeletedContactRoles',\n 'syncContact',\n 'syncOpportunity',\n ]);\n\n $config = $this->createMock(Configuration::class);\n $hasMany = $this->createMock(HasManyExtended::class);\n $hasMany->expects($this->exactly(2))\n ->method('where')\n ->willReturn($hasMany);\n\n $hasMany->expects($this->exactly(2))\n ->method('first')\n ->willReturn(\n $this->createMock(Contact::class),\n $this->createMock(Opportunity::class)\n );\n\n $config->expects($this->once())\n ->method('contacts')\n ->willReturn($hasMany);\n $config->expects($this->once())\n ->method('opportunities')\n ->willReturn($hasMany);\n\n $serviceMock->config = $config;\n\n $serviceMock->expects($this->once())\n ->method('getContactRolesFromCrm')\n ->willReturn($contactRoles);\n\n $serviceMock->expects($this->once())\n ->method('syncRemotelyDeletedContactRoles');\n\n $data = $serviceMock->syncContactRoles(now()->subDay());\n\n $this->assertEquals(1, $data);\n }\n\n public function testSyncRemotelyDeletedContactRoles(): void\n {\n $contactRoles = [\n [\n 'id' => '1',\n 'crm_provider_id' => '1',\n ],\n ];\n\n app()->bind(QueryHandler::class, function () use ($contactRoles) {\n $queryResults = new QueryResults($contactRoles, 1, true, null);\n\n $handler = $this->createMock(QueryHandler::class);\n $handler->method('queryDeleted')\n ->willReturn($queryResults);\n\n return $handler;\n });\n\n app()->bind(ContactRoleRepository::class, function () {\n $contactRoleRepository = $this->createMock(ContactRoleRepository::class);\n $contactRoleRepository->expects($this->once())\n ->method('deleteContactRoles');\n\n return $contactRoleRepository;\n });\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $this->createMock(Team::class);\n\n $data = $this->invokePrivateMethod('syncRemotelyDeletedContactRoles', $serviceMock, []);\n\n $this->assertTrue($data);\n }\n\n private function bindQueryIterator(array $queryResult): void\n {\n /** @var Client $client */\n $client = $this->createMock(Client::class);\n $queryIterator = new QueryIterator(\n $client,\n new QueryResults($queryResult, 1, true, null)\n );\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('query')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n }\n\n public static function getOpportunitySortOrderDataProvider(): array\n {\n return [\n 'all open recently updated' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED, ['LastModifiedDate DESC', true]],\n 'all open recently created' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED, ['CreatedDate DESC', true]],\n 'all open oldest created' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED, ['CreatedDate ASC', true]],\n 'all recently updated' => [Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED, ['LastModifiedDate DESC', false]],\n 'default' => ['unknown', ['LastModifiedDate DESC', true]],\n ];\n }\n\n private function createMockedConfiguration(): Configuration\n {\n $config = $this->createMock(Configuration::class);\n $profilesRelation = $this->getMockBuilder(\\Illuminate\\Database\\Eloquent\\Relations\\HasMany::class)\n ->disableOriginalConstructor()\n ->onlyMethods(['get'])\n ->addMethods(['where', 'first'])\n ->getMock();\n $profilesRelation->method('where')->willReturnSelf();\n $profilesRelation->method('get')->willReturn(collect([]));\n $profilesRelation->method('first')->willReturn(null);\n $config->method('profiles')->willReturn($profilesRelation);\n\n return $config;\n }\n\n private function getServiceMock(array $onlyMethods = []): MockObject&Service\n {\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $this->createMock(Client::class),\n $this->createMock(PayloadBuilder::class),\n $this->createMock(Dispatcher::class),\n $this->createMock(CountriesMap::class),\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods($onlyMethods)\n ->getMock();\n\n $serviceMock->profile = $this->createMock(Profile::class);\n\n return $serviceMock;\n }\n\n private function mockQueryBuilderAndHandler($userToSearch, $salesforceUsers): void\n {\n app()->bind(QueryBuilder::class, function () use ($userToSearch) {\n $queryBuilder = $this->createMock(QueryBuilder::class);\n $queryBuilder->expects($this->once())\n ->method('buildGetUsersQuery')\n ->with($userToSearch)\n ->willReturn('SELECT * FROM Users');\n\n return $queryBuilder;\n });\n\n $queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults($salesforceUsers, count($salesforceUsers), true, null));\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('query')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n }\n\n private function mockTeamRepository(\n Team $team,\n array $salesforceUser,\n ?User $user = null,\n bool $userSearch = true,\n array $customRules = []\n ): void {\n $teamRepository = $this->getMockForAbstractClass(TeamRepository::class, [], '', false, true, true, ['findActiveTeamMemberByEmail', 'getTeamSetting']);\n\n if ($userSearch) {\n $teamRepository->expects($this->once())\n ->method('findActiveTeamMemberByEmail')\n ->with($team, $salesforceUser['Email'])\n ->willReturn($user);\n }\n\n $teamSettings = $this->createMock(TeamSettings::class);\n $teamSettings->method('getValueType')\n ->willReturn('array');\n\n $teamSettings->method('getValue')\n ->willReturn(json_encode($customRules));\n\n $teamRepository->expects($this->once())\n ->method('getTeamSetting')\n ->with($team, 'custom_profile_validation')\n ->willReturn($teamSettings);\n\n app()->bind(TeamRepository::class, function () use ($teamRepository) {\n return $teamRepository;\n });\n }\n\n private function mockProfileRepository(User $user): void\n {\n $profileRepository = $this->createMock(ProfileRepository::class);\n $profileRepository->expects($this->once())\n ->method('updateOrCreateProfile')\n ->with(\n $user,\n [\n 'crm_configuration_id' => 1,\n 'crm_provider_id' => '456',\n ],\n [\n 'user_id' => 123,\n 'edition' => Profile::EDITION_LIGHTNING,\n 'has_external_cti' => true,\n 'crm_profile_id' => '789',\n ]\n )\n ->willReturn(new Profile());\n\n $this->app->instance(ProfileRepository::class, $profileRepository);\n }\n\n public function testBuildEnhancedNoteDecodesHtmlEntities(): void\n {\n $service = $this->getServiceMock(['createRecord']);\n\n $profile = new Profile();\n $profile->setAttribute('crm_provider_id', 'owner-123');\n $service->profile = $profile;\n\n $service->expects($this->exactly(2))\n ->method('createRecord')\n ->willReturnOnConsecutiveCalls('note-id-123', 'link-id-456');\n\n $bodyWithEntities = 'Welch's current challenges and Facebook's Club';\n\n $result = $this->invokePrivateMethod('buildEnhancedNote', $service, [\n 'Test Title',\n $bodyWithEntities,\n 'object-id-789',\n ]);\n\n $this->assertEquals('note-id-123', $result);\n }\n\n public function testBuildEnhancedNoteSanitizesWithoutQuotes(): void\n {\n $service = $this->getServiceMock(['createRecord']);\n\n $profile = new Profile();\n $profile->setAttribute('crm_provider_id', 'owner-456');\n $service->profile = $profile;\n\n $service->expects($this->exactly(2))\n ->method('createRecord')\n ->willReturnCallback(function ($type, $data) {\n if ($type === 'ContentNote') {\n $decoded = base64_decode($data['Content']);\n $this->assertStringContainsString(\"Welch's\", $decoded);\n $this->assertStringNotContainsString(''', $decoded);\n $this->assertStringNotContainsString('&#039;', $decoded);\n $this->assertStringContainsString('<script>', $decoded);\n\n return 'note-id-456';\n }\n\n return 'link-id-789';\n });\n\n $bodyWithMixedContent = \"Welch's and <script>alert('xss')</script>\";\n\n $result = $this->invokePrivateMethod('buildEnhancedNote', $service, [\n 'Test Title',\n $bodyWithMixedContent,\n 'object-id-123',\n ]);\n\n $this->assertEquals('note-id-456', $result);\n }\n\n public function testBuildEnhancedNoteConvertsLineBreaks(): void\n {\n $service = $this->getServiceMock(['createRecord']);\n\n $profile = new Profile();\n $profile->setAttribute('crm_provider_id', 'owner-789');\n $service->profile = $profile;\n\n $service->expects($this->exactly(2))\n ->method('createRecord')\n ->willReturnCallback(function ($type, $data) {\n if ($type === 'ContentNote') {\n $decoded = base64_decode($data['Content']);\n $this->assertStringContainsString('<br>', $decoded);\n $this->assertStringNotContainsString('<br />', $decoded);\n\n return 'note-id-789';\n }\n\n return 'link-id-012';\n });\n\n $bodyWithLineBreaks = \"Line 1\\nLine 2\\nLine 3\";\n\n $result = $this->invokePrivateMethod('buildEnhancedNote', $service, [\n 'Test Title',\n $bodyWithLineBreaks,\n 'object-id-456',\n ]);\n\n $this->assertEquals('note-id-789', $result);\n }\n\n public function testBuildEnhancedNoteHandlesComplexScenario(): void\n {\n $service = $this->getServiceMock(['createRecord']);\n\n $profile = new Profile();\n $profile->setAttribute('crm_provider_id', 'owner-complex');\n $service->profile = $profile;\n\n $service->expects($this->exactly(2))\n ->method('createRecord')\n ->willReturnCallback(function ($type, $data) {\n if ($type === 'ContentNote') {\n $decoded = base64_decode($data['Content']);\n\n $this->assertStringContainsString(\"Welch's\", $decoded);\n $this->assertStringContainsString(\"Facebook's Club\", $decoded);\n $this->assertStringContainsString(\"Arctics'\", $decoded);\n $this->assertStringNotContainsString(''', $decoded);\n $this->assertStringNotContainsString('&#039;', $decoded);\n $this->assertStringContainsString('<br>', $decoded);\n $this->assertStringContainsString('<', $decoded);\n $this->assertStringContainsString('>', $decoded);\n\n return 'note-complex';\n }\n\n return 'link-complex';\n });\n\n $complexBody = \"Summary:\\n---------\\nThe call focused on understanding Welch's current challenges and exploring how Arctics' Revenue Growth Management solutions could support their strategic goals.\\n\\n• John SMith discussed his role as a category advisor for Google and Facebook's Club, emphasizing the importance of market research and advising on product assortment.\\n• Madona introduced Arctics' Virtual Shoppers AI, which simulates consumer <behavior> to optimize pricing and promotional strategies.\";\n\n $result = $this->invokePrivateMethod('buildEnhancedNote', $service, [\n 'Jiminny Transcription Summary',\n $complexBody,\n 'task-id-001',\n ]);\n\n $this->assertEquals('note-complex', $result);\n }\n\n public function testSyncRemotelyDeletedObjectsWithErrorHandlingSuccess(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['syncRemotelyDeletedObjects'])\n ->getMock();\n\n // Mock team\n $team = $this->createMock(Team::class);\n $team->method('getUuid')->willReturn('team-uuid-123');\n $serviceMock->team = $team;\n\n // Expect syncRemotelyDeletedObjects to be called once and succeed\n $serviceMock->expects($this->once())\n ->method('syncRemotelyDeletedObjects')\n ->with(\\Jiminny\\Enums\\CrmObject::ACCOUNT);\n\n // Call the protected method using reflection\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');\n $method->setAccessible(true);\n\n // Should not throw any exceptions\n $method->invoke($serviceMock, \\Jiminny\\Enums\\CrmObject::ACCOUNT);\n\n $this->assertTrue(true); // Test completed successfully\n }\n\n public function testSyncRemotelyDeletedObjectsWithErrorHandlingFailure(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['syncRemotelyDeletedObjects'])\n ->getMock();\n\n // Mock team\n $team = $this->createMock(Team::class);\n $team->method('getUuid')->willReturn('team-uuid-456');\n $serviceMock->team = $team;\n\n // Mock logger to verify warning is logged\n $logger = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n\n // Use reflection to set the protected logger property\n $reflection = new \\ReflectionClass($serviceMock);\n $loggerProperty = $reflection->getProperty('logger');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($serviceMock, $logger);\n\n $exception = new \\Exception('Sync failed due to API error');\n\n // Expect syncRemotelyDeletedObjects to throw an exception\n $serviceMock->expects($this->once())\n ->method('syncRemotelyDeletedObjects')\n ->with(\\Jiminny\\Enums\\CrmObject::CONTACT)\n ->willThrowException($exception);\n\n // Expect warning to be logged with correct message and parameters\n $logger->expects($this->once())\n ->method('warning')\n ->with(\n '[Salesforce] Remotely deleted objects sync failed',\n [\n 'objectType' => 'contact',\n 'teamId' => 'team-uuid-456',\n 'reason' => 'Sync failed due to API error',\n ]\n );\n\n // Call the protected method using reflection\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');\n $method->setAccessible(true);\n\n // Should not re-throw the exception, just log it\n $method->invoke($serviceMock, \\Jiminny\\Enums\\CrmObject::CONTACT);\n\n $this->assertTrue(true); // Test completed successfully\n }\n\n public function testSyncRemotelyDeletedObjectsWithErrorHandlingWithLogParams(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['syncRemotelyDeletedObjects'])\n ->getMock();\n\n // Mock team\n $team = $this->createMock(Team::class);\n $team->method('getUuid')->willReturn('team-uuid-789');\n $serviceMock->team = $team;\n\n // Mock logger to verify warning is logged\n $logger = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n\n // Use reflection to set the protected logger property\n $loggerReflection = new \\ReflectionClass($serviceMock);\n $loggerProperty = $loggerReflection->getProperty('logger');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($serviceMock, $logger);\n\n $exception = new \\Exception('Network timeout');\n\n // Expect syncRemotelyDeletedObjects to throw an exception\n $serviceMock->expects($this->once())\n ->method('syncRemotelyDeletedObjects')\n ->with(\\Jiminny\\Enums\\CrmObject::OPPORTUNITY)\n ->willThrowException($exception);\n\n // Additional log parameters\n $logParams = [\n 'syncType' => 'full',\n 'batchSize' => 100,\n ];\n\n // Expect warning to be logged with merged parameters\n $logger->expects($this->once())\n ->method('warning')\n ->with(\n '[Salesforce] Remotely deleted objects sync failed',\n [\n 'objectType' => 'opportunity',\n 'teamId' => 'team-uuid-789',\n 'reason' => 'Network timeout',\n 'syncType' => 'full',\n 'batchSize' => 100,\n ]\n );\n\n // Call the protected method using reflection\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');\n $method->setAccessible(true);\n\n // Should not re-throw the exception, just log it\n $method->invoke($serviceMock, \\Jiminny\\Enums\\CrmObject::OPPORTUNITY, $logParams);\n\n $this->assertTrue(true); // Test completed successfully\n }\n\n /**\n * @dataProvider crmObjectProvider\n */\n public function testSyncRemotelyDeletedObjectsWithErrorHandlingDifferentCrmObjects(\\Jiminny\\Enums\\CrmObject $crmObject): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['syncRemotelyDeletedObjects'])\n ->getMock();\n\n // Mock team\n $team = $this->createMock(Team::class);\n $team->method('getUuid')->willReturn('team-uuid-test');\n $serviceMock->team = $team;\n\n // Mock logger to verify warning is logged\n $logger = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n\n // Use reflection to set the protected logger property\n $loggerReflectionClass = new \\ReflectionClass($serviceMock);\n $loggerProperty = $loggerReflectionClass->getProperty('logger');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($serviceMock, $logger);\n\n $exception = new \\Exception('Test error');\n\n // Expect syncRemotelyDeletedObjects to throw an exception\n $serviceMock->expects($this->once())\n ->method('syncRemotelyDeletedObjects')\n ->with($crmObject)\n ->willThrowException($exception);\n\n // Expect warning to be logged with correct entity type\n $logger->expects($this->once())\n ->method('warning')\n ->with(\n '[Salesforce] Remotely deleted objects sync failed',\n [\n 'objectType' => $crmObject->value,\n 'teamId' => 'team-uuid-test',\n 'reason' => 'Test error',\n ]\n );\n\n // Call the protected method using reflection\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');\n $method->setAccessible(true);\n\n $method->invoke($serviceMock, $crmObject);\n\n $this->assertTrue(true); // Test completed successfully\n }\n\n public function testHandleObjectDeletionWithDeletedEntity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['deleteCrmObject'])\n ->getMock();\n\n $entity = $this->createMock(\\Jiminny\\Models\\Account::class);\n $crmData = ['IsDeleted' => true];\n\n $serviceMock->expects($this->once())\n ->method('deleteCrmObject')\n ->with($entity);\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('handleObjectDeletion');\n $method->setAccessible(true);\n\n $method->invoke($serviceMock, $entity, $crmData);\n }\n\n public function testHandleObjectDeletionWithNonDeletedEntity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['deleteCrmObject'])\n ->getMock();\n\n $entity = $this->createMock(\\Jiminny\\Models\\Contact::class);\n $crmData = ['IsDeleted' => false];\n\n $serviceMock->expects($this->never())\n ->method('deleteCrmObject');\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('handleObjectDeletion');\n $method->setAccessible(true);\n\n $method->invoke($serviceMock, $entity, $crmData);\n }\n\n public function testDeleteCrmObjectWithValidEntity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['dispatchDeleteCrmObjectJob'])\n ->getMock();\n\n $entity = $this->createMock(\\Jiminny\\Models\\Lead::class);\n $entity->expects($this->once())->method('delete');\n\n $serviceMock->expects($this->once())\n ->method('dispatchDeleteCrmObjectJob')\n ->with($entity);\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('deleteCrmObject');\n $method->setAccessible(true);\n\n $method->invoke($serviceMock, $entity);\n }\n\n public function testDeleteCrmObjectWithNullEntity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['dispatchDeleteCrmObjectJob'])\n ->getMock();\n\n $serviceMock->expects($this->never())\n ->method('dispatchDeleteCrmObjectJob');\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('deleteCrmObject');\n $method->setAccessible(true);\n\n $method->invoke($serviceMock, null);\n }\n\n public function testDispatchDeleteCrmObjectJobWithNullEntity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($service);\n $method = $reflection->getMethod('dispatchDeleteCrmObjectJob');\n $method->setAccessible(true);\n\n // Should return early without dispatching - no exception expected\n $method->invoke($service, null);\n\n $this->assertTrue(true); // Test completed successfully\n }\n\n public function testDispatchDeleteCrmObjectJobWithUnsupportedEntity(): void\n {\n $this->expectException(\\TypeError::class);\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n $unsupportedEntity = $this->createMock(\\stdClass::class);\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($service);\n $method = $reflection->getMethod('dispatchDeleteCrmObjectJob');\n\n // This will throw TypeError due to union type constraint\n $method->invoke($service, $unsupportedEntity);\n }\n\n public function testHandleEntityDeletionByProviderIdMethodExists(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n // Test that the method exists and is accessible via reflection\n $reflection = new \\ReflectionClass($service);\n $method = $reflection->getMethod('handleEntityDeletionByProviderId');\n $method->setAccessible(true);\n\n // Verify method exists and has correct parameters\n $this->assertTrue($method->isProtected());\n $this->assertEquals(2, $method->getNumberOfParameters());\n\n $parameters = $method->getParameters();\n $this->assertEquals('targetEntity', $parameters[0]->getName());\n $this->assertEquals('crmData', $parameters[1]->getName());\n }\n\n public function testSyncRemotelyDeletedObjectsWithNoResults(): void\n {\n // Create a real service instance to avoid mock property issues\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n // Mock queryHandler to throw NoResultsException\n $queryHandler = $this->createMock(QueryHandler::class);\n $queryHandler->expects($this->once())\n ->method('queryDeleted')\n ->with('Opportunity')\n ->willThrowException(new NoResultsException('No results'));\n\n // Set the queryHandler using reflection on the real service\n $reflection = new \\ReflectionClass($service);\n $queryHandlerProperty = $reflection->getProperty('queryHandler');\n $queryHandlerProperty->setAccessible(true);\n $queryHandlerProperty->setValue($service, $queryHandler);\n\n $result = self::invokePrivateMethod('syncRemotelyDeletedObjects', $service, [CrmObject::OPPORTUNITY]);\n\n $this->assertFalse($result);\n }\n\n public function testSyncRemotelyDeletedObjectsWithEmptyResults(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n // Mock queryHandler to return empty results\n $queryResult = $this->createMock(QueryResults::class);\n $queryResult->method('getResults')->willReturn([]);\n\n $queryHandler = $this->createMock(QueryHandler::class);\n $queryHandler->expects($this->once())\n ->method('queryDeleted')\n ->with('Opportunity')\n ->willReturn($queryResult);\n\n // Set the queryHandler using reflection on the real service\n $reflection = new \\ReflectionClass($service);\n $queryHandlerProperty = $reflection->getProperty('queryHandler');\n $queryHandlerProperty->setAccessible(true);\n $queryHandlerProperty->setValue($service, $queryHandler);\n\n $result = self::invokePrivateMethod('syncRemotelyDeletedObjects', $service, [CrmObject::OPPORTUNITY]);\n\n $this->assertFalse($result);\n }\n\n public function testSyncRemotelyDeletedObjectsWithUnsupportedCrmObject(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n // Mock queryHandler to return some deleted objects so we reach the match statement\n $deletedObjects = [\n ['id' => 'task1'],\n ['id' => 'task2'],\n ];\n $queryResult = $this->createMock(QueryResults::class);\n $queryResult->method('getResults')->willReturn($deletedObjects);\n\n $queryHandler = $this->createMock(QueryHandler::class);\n $queryHandler->expects($this->once())\n ->method('queryDeleted')\n ->with('Task') // ucfirst('task') = 'Task'\n ->willReturn($queryResult);\n\n self::setPrivateProperty($service, 'queryHandler', $queryHandler);\n\n $this->expectException(InvalidArgumentException::class);\n $this->expectExceptionMessage('Unsupported CrmObject: task');\n\n self::invokePrivateMethod('syncRemotelyDeletedObjects', $service, [CrmObject::TASK]);\n }\n\n public static function crmObjectProvider(): array\n {\n return [\n 'Account' => [CrmObject::ACCOUNT],\n 'Contact' => [CrmObject::CONTACT],\n 'Lead' => [CrmObject::LEAD],\n 'Opportunity' => [CrmObject::OPPORTUNITY],\n ];\n }\n\n public function testVerifyTaskExistsReturnsTrue(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-123', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-123');\n $activity->method('getId')->willReturn(456);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-123', ['Id', 'IsDeleted'])\n ->willReturn(['Id' => 'task-123', 'IsDeleted' => false]);\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertTrue($result);\n }\n\n public function testVerifyTaskExistsReturnsTrueForEvent(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:event-123', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('event-123');\n $activity->method('getId')->willReturn(456);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_EVENT);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Event', 'event-123', ['Id', 'IsDeleted'])\n ->willReturn(['Id' => 'event-123', 'IsDeleted' => false]);\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertTrue($result);\n }\n\n public function testVerifyTaskExistsReturnsFalseWhenDeleted(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-456', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-456');\n $activity->method('getId')->willReturn(789);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-456', ['Id', 'IsDeleted'])\n ->willReturn(['Id' => 'task-456', 'IsDeleted' => true]);\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertFalse($result);\n }\n\n public function testVerifyTaskExistsReturnsFalseWhenNotFound(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-999', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-999');\n $activity->method('getId')->willReturn(999);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-999', ['Id', 'IsDeleted'])\n ->willThrowException(new \\Jiminny\\Exceptions\\HttpNotFoundException('Task not found'));\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertFalse($result);\n }\n\n public function testVerifyTaskExistsReturnsFalseWhenNoPlaybook(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-no-playbook', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-no-playbook');\n $activity->method('getId')->willReturn(111);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn(null);\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertFalse($result);\n }\n\n public function testVerifyTaskExistsThrowsExceptionForTransientErrors(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-error', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-error');\n $activity->method('getId')->willReturn(888);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-error', ['Id', 'IsDeleted'])\n ->willThrowException(new \\RuntimeException('Network timeout'));\n\n $this->expectException(\\RuntimeException::class);\n $this->expectExceptionMessage('Network timeout');\n\n $service->verifyTaskExists($activity);\n }\n\n public function testVerifyTaskExistsCachesResults(): void\n {\n $cachedValue = null;\n Cache::shouldReceive('remember')\n ->twice()\n ->with('crm_task_exists:123:task-cached', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(function ($key, $ttl, $callback) use (&$cachedValue) {\n if ($cachedValue === null) {\n $cachedValue = $callback();\n }\n\n return $cachedValue;\n });\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-cached');\n $activity->method('getId')->willReturn(555);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-cached', ['Id', 'IsDeleted'])\n ->willReturn(['Id' => 'task-cached', 'IsDeleted' => false]);\n\n $result1 = $service->verifyTaskExists($activity);\n $result2 = $service->verifyTaskExists($activity);\n\n $this->assertTrue($result1);\n $this->assertTrue($result2);\n }\n\n public function testVerifyTaskExistsReturnsFalseForHttpBadRequestException(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-400', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-400');\n $activity->method('getId')->willReturn(400);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-400', ['Id', 'IsDeleted'])\n ->willThrowException(new \\Jiminny\\Exceptions\\HttpBadRequestException('Bad request'));\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertFalse($result);\n }\n\n public function testImportOpportunitySkipsWhenNoProfileAndNoAccount(): void\n {\n $crmData = [\n 'Id' => 'SF-NO-USER-1',\n 'Name' => 'Test Opportunity',\n 'OwnerId' => 'owner-no-profile',\n // No AccountId\n ];\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(\\Illuminate\\Events\\Dispatcher::class); // ← ADD THIS\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n\n $service = new Service(\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService\n );\n\n $config = $this->createMock(Configuration::class);\n\n // Mock profiles relation returning null (no profile found)\n $profilesRelation = \\Mockery::mock(\\Illuminate\\Database\\Eloquent\\Relations\\HasMany::class);\n $profilesRelation->shouldReceive('where')->with('crm_provider_id', 'owner-no-profile')->andReturnSelf();\n $profilesRelation->shouldReceive('first')->andReturn(null);\n\n $config->expects($this->once())\n ->method('profiles')\n ->willReturn($profilesRelation);\n\n $team = $this->createMock(Team::class);\n $team->method('getId')->willReturn(1);\n\n $logger = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n $logger->expects($this->once())\n ->method('error')\n ->with(\n '[Salesforce] | Skip import, no user_id found',\n ['id' => 'SF-NO-USER-1']\n );\n\n $reflection = new \\ReflectionClass($service);\n\n $configProperty = $reflection->getProperty('config');\n $configProperty->setAccessible(true);\n $configProperty->setValue($service, $config);\n\n $teamProperty = $reflection->getProperty('team');\n $teamProperty->setAccessible(true);\n $teamProperty->setValue($service, $team);\n\n $loggerProperty = $reflection->getProperty('logger');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($service, $logger);\n\n // Initialize profile property to avoid \"must not be accessed before initialization\" error\n $profileProperty = $reflection->getProperty('profile');\n $profileProperty->setAccessible(true);\n $profileProperty->setValue($service, null);\n\n $result = self::invokePrivateMethod('importOpportunity', $service, [$crmData]);\n\n $this->assertNull($result);\n }\n\n public function testImportOpportunitySkipsWhenProfileNotFound(): void\n {\n $crmData = [\n 'Id' => 'SF-NO-USER-2',\n 'Name' => 'Test Opportunity',\n 'OwnerId' => 'owner-not-found',\n // No AccountId - avoid complex account processing\n ];\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $eventDispatcher = $this->createMock(\\Illuminate\\Events\\Dispatcher::class); // ← ADD THIS\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n\n\n $service = new Service(\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService\n );\n\n $config = $this->createMock(Configuration::class);\n\n // Mock profiles relation returning null (no profile found)\n $profilesRelation = \\Mockery::mock(\\Illuminate\\Database\\Eloquent\\Relations\\HasMany::class);\n $profilesRelation->shouldReceive('where')->with('crm_provider_id', 'owner-not-found')->andReturnSelf();\n $profilesRelation->shouldReceive('first')->andReturn(null);\n\n $config->expects($this->once())\n ->method('profiles')\n ->willReturn($profilesRelation);\n\n $team = $this->createMock(Team::class);\n $team->method('getId')->willReturn(1);\n\n $logger = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n $logger->expects($this->once())\n ->method('error')\n ->with(\n '[Salesforce] | Skip import, no user_id found',\n ['id' => 'SF-NO-USER-2']\n );\n\n $reflection = new \\ReflectionClass($service);\n\n $configProperty = $reflection->getProperty('config');\n $configProperty->setAccessible(true);\n $configProperty->setValue($service, $config);\n\n $teamProperty = $reflection->getProperty('team');\n $teamProperty->setAccessible(true);\n $teamProperty->setValue($service, $team);\n\n $loggerProperty = $reflection->getProperty('logger');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($service, $logger);\n\n // Initialize profile property\n $profileProperty = $reflection->getProperty('profile');\n $profileProperty->setAccessible(true);\n $profileProperty->setValue($service, null);\n\n $result = self::invokePrivateMethod('importOpportunity', $service, [$crmData]);\n\n $this->assertNull($result);\n }\n}\n\nclass HasManyExtended extends HasMany\n{\n public function where()\n {\n }\n\n public function first()\n {\n }\n}","depth":4,"bounds":{"left":0.16223404,"top":0.09736632,"width":0.3550532,"height":0.90263367},"on_screen":true,"value":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Services\\Crm\\Salesforce;\n\nuse Illuminate\\Database\\Eloquent\\Relations\\HasMany;\nuse Illuminate\\Events\\Dispatcher;\nuse Illuminate\\Support\\Facades\\Cache;\nuse Jiminny\\Component\\Country\\CountriesMap;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Enums\\CrmObject;\nuse Jiminny\\Exceptions\\InvalidArgumentException;\nuse Jiminny\\Exceptions\\NoResultsException;\nuse Jiminny\\Integrations\\PlaybookResolver;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Crm\\Configuration;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\TeamSettings;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\ContactRoleRepository;\nuse Jiminny\\Repositories\\Crm\\FieldDataRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\Salesforce\\Client;\nuse Jiminny\\Services\\Crm\\Salesforce\\PayloadBuilder;\nuse Jiminny\\Services\\Crm\\Salesforce\\QueryBuilder;\nuse Jiminny\\Services\\Crm\\Salesforce\\QueryHandler;\nuse Jiminny\\Services\\Crm\\Salesforce\\QueryIterator;\nuse Jiminny\\Services\\Crm\\Salesforce\\QueryResults;\nuse Jiminny\\Services\\Crm\\Salesforce\\Service;\nuse PHPUnit\\Framework\\MockObject\\MockObject;\nuse Tests\\TestCase;\nuse Tests\\Unit\\Traits\\TestPrivateMethod;\n\nclass ServiceTest extends TestCase\n{\n use TestPrivateMethod;\n\n public function testFetchAndAssociateRelatedActivity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $payloadBuilder->method('addCustomLogicFieldsPayload')\n ->willReturnCallback(function ($activity, $payload) {\n return $payload;\n });\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods(['fetchRelatedActivity', 'getPlaybook', 'getPlaybookCategory', 'updateRecord'])\n ->getMock();\n\n $serviceMock->expects($this->once())\n ->method('fetchRelatedActivity')\n ->willReturn([\n 'Id' => 'testId',\n 'Type' => null,\n 'OwnerId' => 'testerUser',\n 'Description' => 'Test description',\n ]);\n\n $user = $this->createMock(User::class);\n $team = $this->createMock(Team::class);\n $user->method('getAttribute')->with('team')->willReturn($team);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityField')->willReturn(null);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_EVENT);\n\n $serviceMock->expects($this->once())\n ->method('getPlaybook')\n ->with($user)\n ->willReturn($playbook);\n\n $serviceMock->expects($this->never())\n ->method('getPlaybookCategory');\n\n $serviceMock->expects($this->never())\n ->method('updateRecord');\n\n $fieldDataRepository = $this->createMock(FieldDataRepository::class);\n $fieldDataRepository->method('getActivityFieldData')->willReturn(collect([]));\n app()->instance(FieldDataRepository::class, $fieldDataRepository);\n\n $config = $this->createMock(Configuration::class);\n $profilesRelation = $this->getMockBuilder(\\Illuminate\\Database\\Eloquent\\Relations\\HasMany::class)\n ->disableOriginalConstructor()\n ->onlyMethods(['get'])\n ->addMethods(['where'])\n ->getMock();\n $profilesRelation->method('where')->willReturnSelf();\n $profilesRelation->method('get')->willReturn(collect([]));\n $config->method('profiles')->willReturn($profilesRelation);\n\n $serviceMock->config = $config;\n $serviceMock->profile = null;\n\n $actualStartTime = \\Carbon\\Carbon::now();\n\n $activity = $this->getMockBuilder(Activity::class)\n ->disableOriginalConstructor()\n ->onlyMethods(['update', 'hasProspect'])\n ->getMock();\n\n $activity->method('update')->willReturn(true);\n $activity->method('hasProspect')->willReturn(true);\n\n $activity->type = Activity::TYPE_CONFERENCE;\n $activity->provider = Activity::PROVIDER_TWILIO;\n $activity->lead_id = 1;\n $activity->user_id = 0;\n $activity->id_string = 'test-activity-id';\n $activity->user = $user;\n\n $activity->actual_start_time = $actualStartTime;\n $activity->uuid = 'c53d8320-f556-4cee-a2f8-5f232f454ca4';\n\n app()->bind(PlaybookResolver::class, function () use ($user) {\n $playbook = $this->createMock(Playbook::class);\n $playbookResolver = $this->createMock(PlaybookResolver::class);\n $playbookResolver->expects($this->once())\n ->method('resolvePlaybookByUser')\n ->with($user)\n ->willReturn($playbook);\n\n return $playbookResolver;\n });\n\n $data = $serviceMock->fetchAndAssociateRelatedActivity($activity);\n\n $this->assertInstanceOf(Activity::class, $data);\n $this->assertEquals(Activity::TYPE_CONFERENCE, $data->getType());\n $this->assertEquals($actualStartTime->getTimestamp(), $data->getActualStartTime()->getTimestamp());\n }\n\n public function testFetchAndAssociateRelatedActivitySkipsForTaskBasedPlaybook(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods(['fetchRelatedActivity', 'getPlaybook'])\n ->getMock();\n\n $user = $this->createMock(User::class);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n $playbook->method('getId')->willReturn(123);\n\n $serviceMock->expects($this->once())\n ->method('getPlaybook')\n ->with($user)\n ->willReturn($playbook);\n\n $serviceMock->expects($this->never())\n ->method('fetchRelatedActivity');\n\n $activity = $this->getMockBuilder(Activity::class)\n ->disableOriginalConstructor()\n ->onlyMethods(['hasProspect', 'getUuid'])\n ->getMock();\n\n $activity->method('hasProspect')->willReturn(true);\n $activity->method('getUuid')->willReturn('c53d8320-f556-4cee-a2f8-5f232f454ca4');\n $activity->type = Activity::TYPE_CONFERENCE;\n $activity->actual_start_time = \\Carbon\\Carbon::now();\n $activity->user = $user;\n\n $result = $serviceMock->fetchAndAssociateRelatedActivity($activity);\n\n $this->assertNull($result);\n }\n\n public function testFetchAndAssociateRelatedActivityReturnsNullForNonConference(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $serviceMock = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)\n );\n\n $activity = $this->getMockBuilder(Activity::class)\n ->disableOriginalConstructor()\n ->getMock();\n\n $activity->type = Activity::TYPE_SOFTPHONE;\n\n $result = $serviceMock->fetchAndAssociateRelatedActivity($activity);\n\n $this->assertNull($result);\n }\n\n public function testFetchAndAssociateRelatedActivityReturnsNullWhenNoStartTime(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $serviceMock = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)\n );\n\n\n $activity = $this->getMockBuilder(Activity::class)\n ->disableOriginalConstructor()\n ->getMock();\n\n $activity->type = Activity::TYPE_CONFERENCE;\n $activity->actual_start_time = null;\n $activity->scheduled_start_time = null;\n\n $result = $serviceMock->fetchAndAssociateRelatedActivity($activity);\n\n $this->assertNull($result);\n }\n\n public function testFetchAndAssociateRelatedActivityReturnsNullWhenNoProspect(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods(['getPlaybook'])\n ->getMock();\n\n $serviceMock->expects($this->never())\n ->method('getPlaybook');\n\n $activity = $this->getMockBuilder(Activity::class)\n ->disableOriginalConstructor()\n ->onlyMethods(['hasProspect', 'getUuid'])\n ->getMock();\n\n $activity->method('hasProspect')->willReturn(false);\n $activity->method('getUuid')->willReturn('c53d8320-f556-4cee-a2f8-5f232f454ca4');\n $activity->type = Activity::TYPE_CONFERENCE;\n $activity->actual_start_time = \\Carbon\\Carbon::now();\n\n $result = $serviceMock->fetchAndAssociateRelatedActivity($activity);\n\n $this->assertNull($result);\n }\n\n public function testMatchExactlyByEmail(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods([])\n ->getMock();\n\n $profile = new Profile();\n $profile->setAttribute('id', bin2hex(random_bytes(8)));\n $serviceMock->profile = $profile;\n\n $team = $this->createMock(Team::class);\n $serviceMock->team = $team;\n\n $data = $serviceMock->matchExactlyByEmail(bin2hex(random_bytes(8)) . 'test_email@testserver.com');\n\n $this->assertEquals(null, $data);\n }\n\n public function testMatchDomainFromEmail(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $queryIterator = $this->createMock(QueryIterator::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $config = $this->createMock(Configuration::class);\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('search')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods(['convertCrmData'])\n ->getMock();\n\n $profile = new Profile();\n $profile->account_fields = 'Field1, Field2, Field3';\n $serviceMock->profile = $profile;\n\n $serviceMock->expects($this->once())\n ->method('convertCrmData')\n ->willReturn(['test']);\n\n $this->app->bind(QueryBuilder::class, function () {\n $queryBuilder = $this->createMock(QueryBuilder::class);\n $queryBuilder->expects($this->once())\n ->method('buildMatchByDomainQuery')\n ->with('test_email@testserver.com')\n ->willReturn('FIND {test_email@testserver.com} IN ALL FIELDS RETURNING Account(Id)');\n\n return $queryBuilder;\n });\n\n $team = $this->createMock(Team::class);\n\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n\n $data = $serviceMock->matchByDomain('test_email@testserver.com');\n\n $this->assertEquals(['test'], $data);\n }\n\n public function testBuildTaskSearchFields(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)\n );\n\n $fields = $service->buildTaskSearchFields();\n\n $expectedFields = ['Id', 'WhoId', 'WhatId', 'AccountId'];\n\n $this->assertEquals($expectedFields, $fields);\n }\n\n public function testMapCrmObjects(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class),\n );\n\n $sampleTask = [\n 'WhoId' => '003sampleWhoId',\n 'AccountId' => 'sampleAccountId',\n 'WhatId' => 'sampleWhatId',\n ];\n\n $activityData = $service->mapCrmObjects($sampleTask);\n\n $expectedActivityData = [\n 'contact' => '003sampleWhoId',\n 'account' => 'sampleAccountId',\n 'opportunity' => 'sampleWhatId',\n ];\n\n $this->assertEquals($expectedActivityData, $activityData);\n }\n\n public function testGetInstalledAppVersion(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n $queryIterator = $this->createMock(QueryIterator::class);\n $queryIterator->expects($this->any())\n ->method('current')->willReturn([\n 'SubscriberPackageVersion' => [\n 'MajorVersion' => '1',\n 'MinorVersion' => '0',\n 'PatchVersion' => '1',\n 'BuildNumber' => '0',\n ],\n ]);\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('metadata')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods(array_diff(get_class_methods(Service::class), ['getInstalledAppVersion']))\n ->getMock();\n\n $version = $serviceMock->getInstalledAppVersion();\n\n $this->assertEquals('1010', $version);\n }\n\n public function testSyncProfiles(): void\n {\n $userToSearch = null;\n\n $team = $this->createMock(Team::class);\n $config = $this->createMockedConfiguration();\n $config->expects($this->once())\n ->method('getId')\n ->willReturn(1);\n\n $salesforceUser = [\n 'Email' => 'test@example.com',\n 'UserPreferencesLightningExperiencePreferred' => true,\n 'CallCenterId' => '123',\n 'Id' => '456',\n 'ProfileId' => '789',\n ];\n\n app()->bind(QueryBuilder::class, function () use ($userToSearch) {\n $queryBuilder = $this->createMock(QueryBuilder::class);\n $queryBuilder->expects($this->once())\n ->method('buildGetUsersQuery')\n ->with($userToSearch)\n ->willReturn('SELECT * FROM Users');\n\n return $queryBuilder;\n });\n\n $queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('query')\n ->with('SELECT * FROM Users')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n\n $user = $this->createMock(User::class);\n $user->expects($this->once())\n ->method('getId')\n ->willReturn(123);\n\n $this->mockTeamRepository($team, $salesforceUser, $user);\n\n $profileRepository = $this->createMock(ProfileRepository::class);\n $profileRepository->expects($this->once())\n ->method('updateOrCreateProfile')\n ->with(\n $user,\n [\n 'crm_configuration_id' => 1,\n 'crm_provider_id' => '456',\n ],\n [\n 'user_id' => 123,\n 'edition' => Profile::EDITION_LIGHTNING,\n 'has_external_cti' => true,\n 'crm_profile_id' => '789',\n ]\n )\n ->willReturn(new Profile());\n\n $this->app->instance(ProfileRepository::class, $profileRepository);\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n $result = $serviceMock->syncProfiles($userToSearch);\n\n $this->assertNull($result);\n }\n\n public function testSyncProfilesEmailIsNull(): void\n {\n $userToSearch = $this->createMock(User::class);\n\n $salesforceUser = [\n 'Email' => null,\n ];\n\n app()->bind(QueryBuilder::class, function () {\n $queryBuilder = $this->createMock(QueryBuilder::class);\n $queryBuilder->expects($this->once())\n ->method('buildGetUsersQuery')\n ->with(null)\n ->willReturn('SELECT * FROM Users');\n\n return $queryBuilder;\n });\n\n $queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->exactly(2))\n ->method('query')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n\n $team = $this->createMock(Team::class);\n $user = $this->createMock(User::class);\n $this->mockTeamRepository($team, $salesforceUser, $user, false);\n\n $config = $this->createMock(Configuration::class);\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n\n $profile = $serviceMock->syncProfiles(null);\n\n $this->assertNull($profile);\n }\n\n public function testSyncProfilesUserToSearchMatchesCurrentUser(): void\n {\n $userToSearch = $this->createMock(User::class);\n $userToSearch->expects($this->once())\n ->method('getId')\n ->willReturn(123);\n\n $team = $this->createMock(Team::class);\n $config = $this->createMock(Configuration::class);\n $config->expects($this->once())\n ->method('getId')\n ->willReturn(1);\n\n $salesforceUser = [\n 'Email' => 'test@example.com',\n 'UserPreferencesLightningExperiencePreferred' => true,\n 'CallCenterId' => '123',\n 'Id' => '456',\n 'ProfileId' => '789',\n ];\n\n app()->bind(QueryBuilder::class, function () use ($userToSearch) {\n $queryBuilder = $this->createMock(QueryBuilder::class);\n $queryBuilder->expects($this->once())\n ->method('buildGetUsersQuery')\n ->with($userToSearch)\n ->willReturn('SELECT * FROM Users');\n\n return $queryBuilder;\n });\n\n $queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('query')\n ->with('SELECT * FROM Users')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n\n $user = $this->createMock(User::class);\n $user->expects($this->exactly(2))\n ->method('getId')\n ->willReturn(123);\n\n $this->mockTeamRepository($team, $salesforceUser, $user);\n\n $profileRepository = $this->createMock(ProfileRepository::class);\n $profileRepository->expects($this->once())\n ->method('updateOrCreateProfile')\n ->with(\n $user,\n [\n 'crm_configuration_id' => 1,\n 'crm_provider_id' => '456',\n ],\n [\n 'user_id' => 123,\n 'edition' => Profile::EDITION_LIGHTNING,\n 'has_external_cti' => true,\n 'crm_profile_id' => '789',\n ]\n )\n ->willReturn(new Profile());\n\n $this->app->instance(ProfileRepository::class, $profileRepository);\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n $profile = $serviceMock->syncProfiles($userToSearch);\n\n $this->assertInstanceOf(Profile::class, $profile);\n }\n\n public function testSyncProfilesWithCustomValidation(): void\n {\n $userToSearch = null;\n\n $team = $this->createMock(Team::class);\n $config = $this->createMockedConfiguration();\n $config->expects($this->atLeastOnce()) // Changed from once() to atLeastOnce()\n ->method('getId')\n ->willReturn(1);\n\n $salesforceUser = [\n 'Email' => 'test@example.com',\n 'UserPreferencesLightningExperiencePreferred' => true,\n 'CallCenterId' => '123',\n 'Id' => '456',\n 'ProfileId' => '789',\n 'CustomField' => 'CustomValue',\n ];\n\n $customRules = [\n ['field' => 'CustomField', 'value' => 'CustomValue'],\n ];\n\n $this->mockQueryBuilderAndHandler($userToSearch, [$salesforceUser]);\n\n $user = $this->createMock(User::class);\n $user->expects($this->once())\n ->method('getId')\n ->willReturn(123);\n\n $this->mockTeamRepository($team, $salesforceUser, $user, true, $customRules);\n\n $this->mockProfileRepository($user);\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n $profile = $serviceMock->syncProfiles($userToSearch);\n\n $this->assertNull($profile);\n }\n\n public function testSyncProfilesWithCustomValidationFailing(): void\n {\n $userToSearch = null;\n\n $team = $this->createMock(Team::class);\n $config = $this->createMockedConfiguration();\n\n $salesforceUser = [\n 'Email' => 'test@example.com',\n 'UserPreferencesLightningExperiencePreferred' => true,\n 'CallCenterId' => '123',\n 'Id' => '456',\n 'ProfileId' => '789',\n 'CustomField' => 'WrongValue',\n ];\n\n $customRules = [\n ['field' => 'CustomField', 'value' => 'CustomValue'],\n ];\n\n $this->mockQueryBuilderAndHandler($userToSearch, [$salesforceUser]);\n\n $teamRepository = $this->getMockForAbstractClass(TeamRepository::class, [], '', false, true, true, ['findActiveTeamMemberByEmail', 'getTeamSetting']);\n\n $teamSettings = $this->createMock(TeamSettings::class);\n $teamSettings->method('getValueType')\n ->willReturn('array');\n\n $teamSettings->method('getValue')\n ->willReturn(json_encode($customRules));\n\n $teamRepository->expects($this->once())\n ->method('getTeamSetting')\n ->with($team, 'custom_profile_validation')\n ->willReturn($teamSettings);\n\n app()->bind(TeamRepository::class, function () use ($teamRepository) {\n return $teamRepository;\n });\n\n $profileRepository = $this->createMock(ProfileRepository::class);\n $profileRepository->expects($this->never())\n ->method('updateOrCreateProfile');\n\n $this->app->instance(ProfileRepository::class, $profileRepository);\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n $result = $serviceMock->syncProfiles($userToSearch);\n\n $this->assertNull($result);\n }\n\n public function testGetContactRolesFromCrm(): void\n {\n $contactRoles = [\n [\n 'Id' => '1',\n 'ContactId' => 'Contact1',\n 'OpportunityId' => 'Opportunity1',\n 'Opportunity' => ['OwnerId' => 'Owner1'],\n 'IsPrimary' => true,\n 'Role' => 'Decision Maker',\n ],\n ];\n\n $expectedResponse = [\n [\n 'id' => '1',\n 'contactId' => 'Contact1',\n 'opportunityId' => 'Opportunity1',\n 'ownerId' => 'Owner1',\n 'isPrimary' => true,\n 'role' => 'Decision Maker',\n ],\n ];\n\n $this->bindQueryIterator($contactRoles);\n\n $serviceMock = $this->getServiceMock();\n\n $data = $serviceMock->getContactRolesFromCrm(now()->subDay());\n\n $this->assertEquals($expectedResponse, $data);\n }\n\n public function testGetContactRolesFromCrmNoResult(): void\n {\n $this->bindQueryIterator([]);\n\n $serviceMock = $this->getServiceMock();\n\n $data = $serviceMock->getContactRolesFromCrm(now()->subDay());\n\n $this->assertEquals([], $data);\n }\n\n public function testSyncContactRoles(): void\n {\n $contactRoles = [\n [\n 'id' => '1',\n 'contactId' => 'Contact1',\n 'opportunityId' => 'Opportunity1',\n 'ownerId' => 'Owner1',\n 'isPrimary' => true,\n 'role' => 'Decision Maker',\n ],\n ];\n\n app()->bind(ContactRoleRepository::class, function () {\n $contactRoleRepository = $this->createMock(ContactRoleRepository::class);\n $contactRoleRepository->expects($this->once())\n ->method('saveContactRoles');\n\n return $contactRoleRepository;\n });\n\n $serviceMock = $this->getServiceMock([\n 'getContactRolesFromCrm',\n 'syncRemotelyDeletedContactRoles',\n 'syncContact',\n 'syncOpportunity',\n ]);\n\n $config = $this->createMock(Configuration::class);\n $hasMany = $this->createMock(HasManyExtended::class);\n $hasMany->expects($this->exactly(2))\n ->method('where')\n ->willReturn($hasMany);\n\n $hasMany->expects($this->exactly(2))\n ->method('first')\n ->willReturn(\n $this->createMock(Contact::class),\n $this->createMock(Opportunity::class)\n );\n\n $config->expects($this->once())\n ->method('contacts')\n ->willReturn($hasMany);\n $config->expects($this->once())\n ->method('opportunities')\n ->willReturn($hasMany);\n\n $serviceMock->config = $config;\n\n $serviceMock->expects($this->once())\n ->method('getContactRolesFromCrm')\n ->willReturn($contactRoles);\n\n $serviceMock->expects($this->once())\n ->method('syncRemotelyDeletedContactRoles');\n\n $data = $serviceMock->syncContactRoles(now()->subDay());\n\n $this->assertEquals(1, $data);\n }\n\n public function testSyncRemotelyDeletedContactRoles(): void\n {\n $contactRoles = [\n [\n 'id' => '1',\n 'crm_provider_id' => '1',\n ],\n ];\n\n app()->bind(QueryHandler::class, function () use ($contactRoles) {\n $queryResults = new QueryResults($contactRoles, 1, true, null);\n\n $handler = $this->createMock(QueryHandler::class);\n $handler->method('queryDeleted')\n ->willReturn($queryResults);\n\n return $handler;\n });\n\n app()->bind(ContactRoleRepository::class, function () {\n $contactRoleRepository = $this->createMock(ContactRoleRepository::class);\n $contactRoleRepository->expects($this->once())\n ->method('deleteContactRoles');\n\n return $contactRoleRepository;\n });\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $this->createMock(Team::class);\n\n $data = $this->invokePrivateMethod('syncRemotelyDeletedContactRoles', $serviceMock, []);\n\n $this->assertTrue($data);\n }\n\n private function bindQueryIterator(array $queryResult): void\n {\n /** @var Client $client */\n $client = $this->createMock(Client::class);\n $queryIterator = new QueryIterator(\n $client,\n new QueryResults($queryResult, 1, true, null)\n );\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('query')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n }\n\n public static function getOpportunitySortOrderDataProvider(): array\n {\n return [\n 'all open recently updated' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED, ['LastModifiedDate DESC', true]],\n 'all open recently created' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED, ['CreatedDate DESC', true]],\n 'all open oldest created' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED, ['CreatedDate ASC', true]],\n 'all recently updated' => [Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED, ['LastModifiedDate DESC', false]],\n 'default' => ['unknown', ['LastModifiedDate DESC', true]],\n ];\n }\n\n private function createMockedConfiguration(): Configuration\n {\n $config = $this->createMock(Configuration::class);\n $profilesRelation = $this->getMockBuilder(\\Illuminate\\Database\\Eloquent\\Relations\\HasMany::class)\n ->disableOriginalConstructor()\n ->onlyMethods(['get'])\n ->addMethods(['where', 'first'])\n ->getMock();\n $profilesRelation->method('where')->willReturnSelf();\n $profilesRelation->method('get')->willReturn(collect([]));\n $profilesRelation->method('first')->willReturn(null);\n $config->method('profiles')->willReturn($profilesRelation);\n\n return $config;\n }\n\n private function getServiceMock(array $onlyMethods = []): MockObject&Service\n {\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $this->createMock(Client::class),\n $this->createMock(PayloadBuilder::class),\n $this->createMock(Dispatcher::class),\n $this->createMock(CountriesMap::class),\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods($onlyMethods)\n ->getMock();\n\n $serviceMock->profile = $this->createMock(Profile::class);\n\n return $serviceMock;\n }\n\n private function mockQueryBuilderAndHandler($userToSearch, $salesforceUsers): void\n {\n app()->bind(QueryBuilder::class, function () use ($userToSearch) {\n $queryBuilder = $this->createMock(QueryBuilder::class);\n $queryBuilder->expects($this->once())\n ->method('buildGetUsersQuery')\n ->with($userToSearch)\n ->willReturn('SELECT * FROM Users');\n\n return $queryBuilder;\n });\n\n $queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults($salesforceUsers, count($salesforceUsers), true, null));\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('query')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n }\n\n private function mockTeamRepository(\n Team $team,\n array $salesforceUser,\n ?User $user = null,\n bool $userSearch = true,\n array $customRules = []\n ): void {\n $teamRepository = $this->getMockForAbstractClass(TeamRepository::class, [], '', false, true, true, ['findActiveTeamMemberByEmail', 'getTeamSetting']);\n\n if ($userSearch) {\n $teamRepository->expects($this->once())\n ->method('findActiveTeamMemberByEmail')\n ->with($team, $salesforceUser['Email'])\n ->willReturn($user);\n }\n\n $teamSettings = $this->createMock(TeamSettings::class);\n $teamSettings->method('getValueType')\n ->willReturn('array');\n\n $teamSettings->method('getValue')\n ->willReturn(json_encode($customRules));\n\n $teamRepository->expects($this->once())\n ->method('getTeamSetting')\n ->with($team, 'custom_profile_validation')\n ->willReturn($teamSettings);\n\n app()->bind(TeamRepository::class, function () use ($teamRepository) {\n return $teamRepository;\n });\n }\n\n private function mockProfileRepository(User $user): void\n {\n $profileRepository = $this->createMock(ProfileRepository::class);\n $profileRepository->expects($this->once())\n ->method('updateOrCreateProfile')\n ->with(\n $user,\n [\n 'crm_configuration_id' => 1,\n 'crm_provider_id' => '456',\n ],\n [\n 'user_id' => 123,\n 'edition' => Profile::EDITION_LIGHTNING,\n 'has_external_cti' => true,\n 'crm_profile_id' => '789',\n ]\n )\n ->willReturn(new Profile());\n\n $this->app->instance(ProfileRepository::class, $profileRepository);\n }\n\n public function testBuildEnhancedNoteDecodesHtmlEntities(): void\n {\n $service = $this->getServiceMock(['createRecord']);\n\n $profile = new Profile();\n $profile->setAttribute('crm_provider_id', 'owner-123');\n $service->profile = $profile;\n\n $service->expects($this->exactly(2))\n ->method('createRecord')\n ->willReturnOnConsecutiveCalls('note-id-123', 'link-id-456');\n\n $bodyWithEntities = 'Welch's current challenges and Facebook's Club';\n\n $result = $this->invokePrivateMethod('buildEnhancedNote', $service, [\n 'Test Title',\n $bodyWithEntities,\n 'object-id-789',\n ]);\n\n $this->assertEquals('note-id-123', $result);\n }\n\n public function testBuildEnhancedNoteSanitizesWithoutQuotes(): void\n {\n $service = $this->getServiceMock(['createRecord']);\n\n $profile = new Profile();\n $profile->setAttribute('crm_provider_id', 'owner-456');\n $service->profile = $profile;\n\n $service->expects($this->exactly(2))\n ->method('createRecord')\n ->willReturnCallback(function ($type, $data) {\n if ($type === 'ContentNote') {\n $decoded = base64_decode($data['Content']);\n $this->assertStringContainsString(\"Welch's\", $decoded);\n $this->assertStringNotContainsString(''', $decoded);\n $this->assertStringNotContainsString('&#039;', $decoded);\n $this->assertStringContainsString('<script>', $decoded);\n\n return 'note-id-456';\n }\n\n return 'link-id-789';\n });\n\n $bodyWithMixedContent = \"Welch's and <script>alert('xss')</script>\";\n\n $result = $this->invokePrivateMethod('buildEnhancedNote', $service, [\n 'Test Title',\n $bodyWithMixedContent,\n 'object-id-123',\n ]);\n\n $this->assertEquals('note-id-456', $result);\n }\n\n public function testBuildEnhancedNoteConvertsLineBreaks(): void\n {\n $service = $this->getServiceMock(['createRecord']);\n\n $profile = new Profile();\n $profile->setAttribute('crm_provider_id', 'owner-789');\n $service->profile = $profile;\n\n $service->expects($this->exactly(2))\n ->method('createRecord')\n ->willReturnCallback(function ($type, $data) {\n if ($type === 'ContentNote') {\n $decoded = base64_decode($data['Content']);\n $this->assertStringContainsString('<br>', $decoded);\n $this->assertStringNotContainsString('<br />', $decoded);\n\n return 'note-id-789';\n }\n\n return 'link-id-012';\n });\n\n $bodyWithLineBreaks = \"Line 1\\nLine 2\\nLine 3\";\n\n $result = $this->invokePrivateMethod('buildEnhancedNote', $service, [\n 'Test Title',\n $bodyWithLineBreaks,\n 'object-id-456',\n ]);\n\n $this->assertEquals('note-id-789', $result);\n }\n\n public function testBuildEnhancedNoteHandlesComplexScenario(): void\n {\n $service = $this->getServiceMock(['createRecord']);\n\n $profile = new Profile();\n $profile->setAttribute('crm_provider_id', 'owner-complex');\n $service->profile = $profile;\n\n $service->expects($this->exactly(2))\n ->method('createRecord')\n ->willReturnCallback(function ($type, $data) {\n if ($type === 'ContentNote') {\n $decoded = base64_decode($data['Content']);\n\n $this->assertStringContainsString(\"Welch's\", $decoded);\n $this->assertStringContainsString(\"Facebook's Club\", $decoded);\n $this->assertStringContainsString(\"Arctics'\", $decoded);\n $this->assertStringNotContainsString(''', $decoded);\n $this->assertStringNotContainsString('&#039;', $decoded);\n $this->assertStringContainsString('<br>', $decoded);\n $this->assertStringContainsString('<', $decoded);\n $this->assertStringContainsString('>', $decoded);\n\n return 'note-complex';\n }\n\n return 'link-complex';\n });\n\n $complexBody = \"Summary:\\n---------\\nThe call focused on understanding Welch's current challenges and exploring how Arctics' Revenue Growth Management solutions could support their strategic goals.\\n\\n• John SMith discussed his role as a category advisor for Google and Facebook's Club, emphasizing the importance of market research and advising on product assortment.\\n• Madona introduced Arctics' Virtual Shoppers AI, which simulates consumer <behavior> to optimize pricing and promotional strategies.\";\n\n $result = $this->invokePrivateMethod('buildEnhancedNote', $service, [\n 'Jiminny Transcription Summary',\n $complexBody,\n 'task-id-001',\n ]);\n\n $this->assertEquals('note-complex', $result);\n }\n\n public function testSyncRemotelyDeletedObjectsWithErrorHandlingSuccess(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['syncRemotelyDeletedObjects'])\n ->getMock();\n\n // Mock team\n $team = $this->createMock(Team::class);\n $team->method('getUuid')->willReturn('team-uuid-123');\n $serviceMock->team = $team;\n\n // Expect syncRemotelyDeletedObjects to be called once and succeed\n $serviceMock->expects($this->once())\n ->method('syncRemotelyDeletedObjects')\n ->with(\\Jiminny\\Enums\\CrmObject::ACCOUNT);\n\n // Call the protected method using reflection\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');\n $method->setAccessible(true);\n\n // Should not throw any exceptions\n $method->invoke($serviceMock, \\Jiminny\\Enums\\CrmObject::ACCOUNT);\n\n $this->assertTrue(true); // Test completed successfully\n }\n\n public function testSyncRemotelyDeletedObjectsWithErrorHandlingFailure(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['syncRemotelyDeletedObjects'])\n ->getMock();\n\n // Mock team\n $team = $this->createMock(Team::class);\n $team->method('getUuid')->willReturn('team-uuid-456');\n $serviceMock->team = $team;\n\n // Mock logger to verify warning is logged\n $logger = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n\n // Use reflection to set the protected logger property\n $reflection = new \\ReflectionClass($serviceMock);\n $loggerProperty = $reflection->getProperty('logger');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($serviceMock, $logger);\n\n $exception = new \\Exception('Sync failed due to API error');\n\n // Expect syncRemotelyDeletedObjects to throw an exception\n $serviceMock->expects($this->once())\n ->method('syncRemotelyDeletedObjects')\n ->with(\\Jiminny\\Enums\\CrmObject::CONTACT)\n ->willThrowException($exception);\n\n // Expect warning to be logged with correct message and parameters\n $logger->expects($this->once())\n ->method('warning')\n ->with(\n '[Salesforce] Remotely deleted objects sync failed',\n [\n 'objectType' => 'contact',\n 'teamId' => 'team-uuid-456',\n 'reason' => 'Sync failed due to API error',\n ]\n );\n\n // Call the protected method using reflection\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');\n $method->setAccessible(true);\n\n // Should not re-throw the exception, just log it\n $method->invoke($serviceMock, \\Jiminny\\Enums\\CrmObject::CONTACT);\n\n $this->assertTrue(true); // Test completed successfully\n }\n\n public function testSyncRemotelyDeletedObjectsWithErrorHandlingWithLogParams(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['syncRemotelyDeletedObjects'])\n ->getMock();\n\n // Mock team\n $team = $this->createMock(Team::class);\n $team->method('getUuid')->willReturn('team-uuid-789');\n $serviceMock->team = $team;\n\n // Mock logger to verify warning is logged\n $logger = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n\n // Use reflection to set the protected logger property\n $loggerReflection = new \\ReflectionClass($serviceMock);\n $loggerProperty = $loggerReflection->getProperty('logger');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($serviceMock, $logger);\n\n $exception = new \\Exception('Network timeout');\n\n // Expect syncRemotelyDeletedObjects to throw an exception\n $serviceMock->expects($this->once())\n ->method('syncRemotelyDeletedObjects')\n ->with(\\Jiminny\\Enums\\CrmObject::OPPORTUNITY)\n ->willThrowException($exception);\n\n // Additional log parameters\n $logParams = [\n 'syncType' => 'full',\n 'batchSize' => 100,\n ];\n\n // Expect warning to be logged with merged parameters\n $logger->expects($this->once())\n ->method('warning')\n ->with(\n '[Salesforce] Remotely deleted objects sync failed',\n [\n 'objectType' => 'opportunity',\n 'teamId' => 'team-uuid-789',\n 'reason' => 'Network timeout',\n 'syncType' => 'full',\n 'batchSize' => 100,\n ]\n );\n\n // Call the protected method using reflection\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');\n $method->setAccessible(true);\n\n // Should not re-throw the exception, just log it\n $method->invoke($serviceMock, \\Jiminny\\Enums\\CrmObject::OPPORTUNITY, $logParams);\n\n $this->assertTrue(true); // Test completed successfully\n }\n\n /**\n * @dataProvider crmObjectProvider\n */\n public function testSyncRemotelyDeletedObjectsWithErrorHandlingDifferentCrmObjects(\\Jiminny\\Enums\\CrmObject $crmObject): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['syncRemotelyDeletedObjects'])\n ->getMock();\n\n // Mock team\n $team = $this->createMock(Team::class);\n $team->method('getUuid')->willReturn('team-uuid-test');\n $serviceMock->team = $team;\n\n // Mock logger to verify warning is logged\n $logger = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n\n // Use reflection to set the protected logger property\n $loggerReflectionClass = new \\ReflectionClass($serviceMock);\n $loggerProperty = $loggerReflectionClass->getProperty('logger');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($serviceMock, $logger);\n\n $exception = new \\Exception('Test error');\n\n // Expect syncRemotelyDeletedObjects to throw an exception\n $serviceMock->expects($this->once())\n ->method('syncRemotelyDeletedObjects')\n ->with($crmObject)\n ->willThrowException($exception);\n\n // Expect warning to be logged with correct entity type\n $logger->expects($this->once())\n ->method('warning')\n ->with(\n '[Salesforce] Remotely deleted objects sync failed',\n [\n 'objectType' => $crmObject->value,\n 'teamId' => 'team-uuid-test',\n 'reason' => 'Test error',\n ]\n );\n\n // Call the protected method using reflection\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');\n $method->setAccessible(true);\n\n $method->invoke($serviceMock, $crmObject);\n\n $this->assertTrue(true); // Test completed successfully\n }\n\n public function testHandleObjectDeletionWithDeletedEntity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['deleteCrmObject'])\n ->getMock();\n\n $entity = $this->createMock(\\Jiminny\\Models\\Account::class);\n $crmData = ['IsDeleted' => true];\n\n $serviceMock->expects($this->once())\n ->method('deleteCrmObject')\n ->with($entity);\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('handleObjectDeletion');\n $method->setAccessible(true);\n\n $method->invoke($serviceMock, $entity, $crmData);\n }\n\n public function testHandleObjectDeletionWithNonDeletedEntity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['deleteCrmObject'])\n ->getMock();\n\n $entity = $this->createMock(\\Jiminny\\Models\\Contact::class);\n $crmData = ['IsDeleted' => false];\n\n $serviceMock->expects($this->never())\n ->method('deleteCrmObject');\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('handleObjectDeletion');\n $method->setAccessible(true);\n\n $method->invoke($serviceMock, $entity, $crmData);\n }\n\n public function testDeleteCrmObjectWithValidEntity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['dispatchDeleteCrmObjectJob'])\n ->getMock();\n\n $entity = $this->createMock(\\Jiminny\\Models\\Lead::class);\n $entity->expects($this->once())->method('delete');\n\n $serviceMock->expects($this->once())\n ->method('dispatchDeleteCrmObjectJob')\n ->with($entity);\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('deleteCrmObject');\n $method->setAccessible(true);\n\n $method->invoke($serviceMock, $entity);\n }\n\n public function testDeleteCrmObjectWithNullEntity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['dispatchDeleteCrmObjectJob'])\n ->getMock();\n\n $serviceMock->expects($this->never())\n ->method('dispatchDeleteCrmObjectJob');\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('deleteCrmObject');\n $method->setAccessible(true);\n\n $method->invoke($serviceMock, null);\n }\n\n public function testDispatchDeleteCrmObjectJobWithNullEntity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($service);\n $method = $reflection->getMethod('dispatchDeleteCrmObjectJob');\n $method->setAccessible(true);\n\n // Should return early without dispatching - no exception expected\n $method->invoke($service, null);\n\n $this->assertTrue(true); // Test completed successfully\n }\n\n public function testDispatchDeleteCrmObjectJobWithUnsupportedEntity(): void\n {\n $this->expectException(\\TypeError::class);\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n $unsupportedEntity = $this->createMock(\\stdClass::class);\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($service);\n $method = $reflection->getMethod('dispatchDeleteCrmObjectJob');\n\n // This will throw TypeError due to union type constraint\n $method->invoke($service, $unsupportedEntity);\n }\n\n public function testHandleEntityDeletionByProviderIdMethodExists(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n // Test that the method exists and is accessible via reflection\n $reflection = new \\ReflectionClass($service);\n $method = $reflection->getMethod('handleEntityDeletionByProviderId');\n $method->setAccessible(true);\n\n // Verify method exists and has correct parameters\n $this->assertTrue($method->isProtected());\n $this->assertEquals(2, $method->getNumberOfParameters());\n\n $parameters = $method->getParameters();\n $this->assertEquals('targetEntity', $parameters[0]->getName());\n $this->assertEquals('crmData', $parameters[1]->getName());\n }\n\n public function testSyncRemotelyDeletedObjectsWithNoResults(): void\n {\n // Create a real service instance to avoid mock property issues\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n // Mock queryHandler to throw NoResultsException\n $queryHandler = $this->createMock(QueryHandler::class);\n $queryHandler->expects($this->once())\n ->method('queryDeleted')\n ->with('Opportunity')\n ->willThrowException(new NoResultsException('No results'));\n\n // Set the queryHandler using reflection on the real service\n $reflection = new \\ReflectionClass($service);\n $queryHandlerProperty = $reflection->getProperty('queryHandler');\n $queryHandlerProperty->setAccessible(true);\n $queryHandlerProperty->setValue($service, $queryHandler);\n\n $result = self::invokePrivateMethod('syncRemotelyDeletedObjects', $service, [CrmObject::OPPORTUNITY]);\n\n $this->assertFalse($result);\n }\n\n public function testSyncRemotelyDeletedObjectsWithEmptyResults(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n // Mock queryHandler to return empty results\n $queryResult = $this->createMock(QueryResults::class);\n $queryResult->method('getResults')->willReturn([]);\n\n $queryHandler = $this->createMock(QueryHandler::class);\n $queryHandler->expects($this->once())\n ->method('queryDeleted')\n ->with('Opportunity')\n ->willReturn($queryResult);\n\n // Set the queryHandler using reflection on the real service\n $reflection = new \\ReflectionClass($service);\n $queryHandlerProperty = $reflection->getProperty('queryHandler');\n $queryHandlerProperty->setAccessible(true);\n $queryHandlerProperty->setValue($service, $queryHandler);\n\n $result = self::invokePrivateMethod('syncRemotelyDeletedObjects', $service, [CrmObject::OPPORTUNITY]);\n\n $this->assertFalse($result);\n }\n\n public function testSyncRemotelyDeletedObjectsWithUnsupportedCrmObject(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n // Mock queryHandler to return some deleted objects so we reach the match statement\n $deletedObjects = [\n ['id' => 'task1'],\n ['id' => 'task2'],\n ];\n $queryResult = $this->createMock(QueryResults::class);\n $queryResult->method('getResults')->willReturn($deletedObjects);\n\n $queryHandler = $this->createMock(QueryHandler::class);\n $queryHandler->expects($this->once())\n ->method('queryDeleted')\n ->with('Task') // ucfirst('task') = 'Task'\n ->willReturn($queryResult);\n\n self::setPrivateProperty($service, 'queryHandler', $queryHandler);\n\n $this->expectException(InvalidArgumentException::class);\n $this->expectExceptionMessage('Unsupported CrmObject: task');\n\n self::invokePrivateMethod('syncRemotelyDeletedObjects', $service, [CrmObject::TASK]);\n }\n\n public static function crmObjectProvider(): array\n {\n return [\n 'Account' => [CrmObject::ACCOUNT],\n 'Contact' => [CrmObject::CONTACT],\n 'Lead' => [CrmObject::LEAD],\n 'Opportunity' => [CrmObject::OPPORTUNITY],\n ];\n }\n\n public function testVerifyTaskExistsReturnsTrue(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-123', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-123');\n $activity->method('getId')->willReturn(456);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-123', ['Id', 'IsDeleted'])\n ->willReturn(['Id' => 'task-123', 'IsDeleted' => false]);\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertTrue($result);\n }\n\n public function testVerifyTaskExistsReturnsTrueForEvent(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:event-123', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('event-123');\n $activity->method('getId')->willReturn(456);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_EVENT);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Event', 'event-123', ['Id', 'IsDeleted'])\n ->willReturn(['Id' => 'event-123', 'IsDeleted' => false]);\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertTrue($result);\n }\n\n public function testVerifyTaskExistsReturnsFalseWhenDeleted(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-456', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-456');\n $activity->method('getId')->willReturn(789);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-456', ['Id', 'IsDeleted'])\n ->willReturn(['Id' => 'task-456', 'IsDeleted' => true]);\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertFalse($result);\n }\n\n public function testVerifyTaskExistsReturnsFalseWhenNotFound(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-999', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-999');\n $activity->method('getId')->willReturn(999);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-999', ['Id', 'IsDeleted'])\n ->willThrowException(new \\Jiminny\\Exceptions\\HttpNotFoundException('Task not found'));\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertFalse($result);\n }\n\n public function testVerifyTaskExistsReturnsFalseWhenNoPlaybook(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-no-playbook', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-no-playbook');\n $activity->method('getId')->willReturn(111);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn(null);\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertFalse($result);\n }\n\n public function testVerifyTaskExistsThrowsExceptionForTransientErrors(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-error', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-error');\n $activity->method('getId')->willReturn(888);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-error', ['Id', 'IsDeleted'])\n ->willThrowException(new \\RuntimeException('Network timeout'));\n\n $this->expectException(\\RuntimeException::class);\n $this->expectExceptionMessage('Network timeout');\n\n $service->verifyTaskExists($activity);\n }\n\n public function testVerifyTaskExistsCachesResults(): void\n {\n $cachedValue = null;\n Cache::shouldReceive('remember')\n ->twice()\n ->with('crm_task_exists:123:task-cached', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(function ($key, $ttl, $callback) use (&$cachedValue) {\n if ($cachedValue === null) {\n $cachedValue = $callback();\n }\n\n return $cachedValue;\n });\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-cached');\n $activity->method('getId')->willReturn(555);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-cached', ['Id', 'IsDeleted'])\n ->willReturn(['Id' => 'task-cached', 'IsDeleted' => false]);\n\n $result1 = $service->verifyTaskExists($activity);\n $result2 = $service->verifyTaskExists($activity);\n\n $this->assertTrue($result1);\n $this->assertTrue($result2);\n }\n\n public function testVerifyTaskExistsReturnsFalseForHttpBadRequestException(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-400', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-400');\n $activity->method('getId')->willReturn(400);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-400', ['Id', 'IsDeleted'])\n ->willThrowException(new \\Jiminny\\Exceptions\\HttpBadRequestException('Bad request'));\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertFalse($result);\n }\n\n public function testImportOpportunitySkipsWhenNoProfileAndNoAccount(): void\n {\n $crmData = [\n 'Id' => 'SF-NO-USER-1',\n 'Name' => 'Test Opportunity',\n 'OwnerId' => 'owner-no-profile',\n // No AccountId\n ];\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(\\Illuminate\\Events\\Dispatcher::class); // ← ADD THIS\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n\n $service = new Service(\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService\n );\n\n $config = $this->createMock(Configuration::class);\n\n // Mock profiles relation returning null (no profile found)\n $profilesRelation = \\Mockery::mock(\\Illuminate\\Database\\Eloquent\\Relations\\HasMany::class);\n $profilesRelation->shouldReceive('where')->with('crm_provider_id', 'owner-no-profile')->andReturnSelf();\n $profilesRelation->shouldReceive('first')->andReturn(null);\n\n $config->expects($this->once())\n ->method('profiles')\n ->willReturn($profilesRelation);\n\n $team = $this->createMock(Team::class);\n $team->method('getId')->willReturn(1);\n\n $logger = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n $logger->expects($this->once())\n ->method('error')\n ->with(\n '[Salesforce] | Skip import, no user_id found',\n ['id' => 'SF-NO-USER-1']\n );\n\n $reflection = new \\ReflectionClass($service);\n\n $configProperty = $reflection->getProperty('config');\n $configProperty->setAccessible(true);\n $configProperty->setValue($service, $config);\n\n $teamProperty = $reflection->getProperty('team');\n $teamProperty->setAccessible(true);\n $teamProperty->setValue($service, $team);\n\n $loggerProperty = $reflection->getProperty('logger');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($service, $logger);\n\n // Initialize profile property to avoid \"must not be accessed before initialization\" error\n $profileProperty = $reflection->getProperty('profile');\n $profileProperty->setAccessible(true);\n $profileProperty->setValue($service, null);\n\n $result = self::invokePrivateMethod('importOpportunity', $service, [$crmData]);\n\n $this->assertNull($result);\n }\n\n public function testImportOpportunitySkipsWhenProfileNotFound(): void\n {\n $crmData = [\n 'Id' => 'SF-NO-USER-2',\n 'Name' => 'Test Opportunity',\n 'OwnerId' => 'owner-not-found',\n // No AccountId - avoid complex account processing\n ];\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $eventDispatcher = $this->createMock(\\Illuminate\\Events\\Dispatcher::class); // ← ADD THIS\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n\n\n $service = new Service(\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService\n );\n\n $config = $this->createMock(Configuration::class);\n\n // Mock profiles relation returning null (no profile found)\n $profilesRelation = \\Mockery::mock(\\Illuminate\\Database\\Eloquent\\Relations\\HasMany::class);\n $profilesRelation->shouldReceive('where')->with('crm_provider_id', 'owner-not-found')->andReturnSelf();\n $profilesRelation->shouldReceive('first')->andReturn(null);\n\n $config->expects($this->once())\n ->method('profiles')\n ->willReturn($profilesRelation);\n\n $team = $this->createMock(Team::class);\n $team->method('getId')->willReturn(1);\n\n $logger = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n $logger->expects($this->once())\n ->method('error')\n ->with(\n '[Salesforce] | Skip import, no user_id found',\n ['id' => 'SF-NO-USER-2']\n );\n\n $reflection = new \\ReflectionClass($service);\n\n $configProperty = $reflection->getProperty('config');\n $configProperty->setAccessible(true);\n $configProperty->setValue($service, $config);\n\n $teamProperty = $reflection->getProperty('team');\n $teamProperty->setAccessible(true);\n $teamProperty->setValue($service, $team);\n\n $loggerProperty = $reflection->getProperty('logger');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($service, $logger);\n\n // Initialize profile property\n $profileProperty = $reflection->getProperty('profile');\n $profileProperty->setAccessible(true);\n $profileProperty->setValue($service, null);\n\n $result = self::invokePrivateMethod('importOpportunity', $service, [$crmData]);\n\n $this->assertNull($result);\n }\n}\n\nclass HasManyExtended extends HasMany\n{\n public function where()\n {\n }\n\n public function first()\n {\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"A","depth":4,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":5,"bounds":{"left":0.27027926,"top":1.0,"width":0.0039893617,"height":0.0},"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Select All","depth":4,"bounds":{"left":0.42652926,"top":0.14365523,"width":0.09009308,"height":0.0207502},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"320","depth":6,"bounds":{"left":0.5169548,"top":0.16520351,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"320","depth":7,"bounds":{"left":0.5169548,"top":0.16520351,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"5772","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"5772","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"46ea38f7-c940-395d-9c79-0329d30e9ab7","depth":6,"bounds":{"left":0.5169548,"top":0.1867518,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"46ea38f7-c940-395d-9c79-0329d30e9ab7","depth":7,"bounds":{"left":0.5169548,"top":0.1867518,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"2024-11-26 09:21:15","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"2024-11-26 09:21:15","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"401","depth":6,"bounds":{"left":0.5169548,"top":0.20830008,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"401","depth":7,"bounds":{"left":0.5169548,"top":0.20830008,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"5772","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"5772","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"<null>","depth":6,"bounds":{"left":0.5169548,"top":0.22984837,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"<null>","depth":7,"bounds":{"left":0.5169548,"top":0.22984837,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"5772","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"5772","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"salesforce","depth":6,"bounds":{"left":0.5169548,"top":0.25139666,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"salesforce","depth":7,"bounds":{"left":0.5169548,"top":0.25139666,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"2024-11-26 09:21:15","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"2024-11-26 09:21:15","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"enterprise","depth":6,"bounds":{"left":0.5169548,"top":0.27294493,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"enterprise","depth":7,"bounds":{"left":0.5169548,"top":0.27294493,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"2024-11-26 09:21:15","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"2024-11-26 09:21:15","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"SWE38","depth":6,"bounds":{"left":0.5169548,"top":0.29449323,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"SWE38","depth":7,"bounds":{"left":0.5169548,"top":0.29449323,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"2024-11-26 09:21:15","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"2024-11-26 09:21:15","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"0","depth":6,"bounds":{"left":0.5169548,"top":0.3160415,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"0","depth":7,"bounds":{"left":0.5169548,"top":0.3160415,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"5772","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"5772","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"50.0","depth":6,"bounds":{"left":0.5169548,"top":0.33758977,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"50.0","depth":7,"bounds":{"left":0.5169548,"top":0.33758977,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"2024-11-26 09:21:15","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"2024-11-26 09:21:15","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"01801","depth":6,"bounds":{"left":0.5169548,"top":0.35913807,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"01801","depth":7,"bounds":{"left":0.5169548,"top":0.35913807,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"2024-11-26 09:21:15","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"2024-11-26 09:21:15","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"1","depth":6,"bounds":{"left":0.5169548,"top":0.38068634,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"1","depth":7,"bounds":{"left":0.5169548,"top":0.38068634,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"5772","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"5772","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"1","depth":6,"bounds":{"left":0.5169548,"top":0.40223464,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"1","depth":7,"bounds":{"left":0.5169548,"top":0.40223464,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"5772","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"5772","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"1","depth":6,"bounds":{"left":0.5169548,"top":0.4237829,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"1","depth":7,"bounds":{"left":0.5169548,"top":0.4237829,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"5772","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"5772","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"00D20000000MdbgEAC","depth":6,"bounds":{"left":0.5169548,"top":0.44533122,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"00D20000000MdbgEAC","depth":7,"bounds":{"left":0.5169548,"top":0.44533122,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"2024-11-26 09:21:15","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"2024-11-26 09:21:15","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"https://bipsolutions.my.salesforce.com","depth":6,"bounds":{"left":0.5169548,"top":0.4668795,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"https://bipsolutions.my.salesforce.com","depth":7,"bounds":{"left":0.5169548,"top":0.4668795,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"2024-11-26 09:21:15","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"2024-11-26 09:21:15","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"2026-03-19 08:19:24","depth":6,"bounds":{"left":0.5169548,"top":0.4884278,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"2026-03-19 08:19:24","depth":7,"bounds":{"left":0.5169548,"top":0.4884278,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"2024-11-26 09:21:15","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"2024-11-26 09:21:15","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"2026-05-22 09:48:21","depth":6,"bounds":{"left":0.5169548,"top":0.509976,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"2026-05-22 09:48:21","depth":7,"bounds":{"left":0.5169548,"top":0.509976,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"2024-11-26 09:21:15","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"2024-11-26 09:21:15","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"2026-05-22 09:48:31","depth":6,"bounds":{"left":0.5169548,"top":0.53152436,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"2026-05-22 09:48:31","depth":7,"bounds":{"left":0.5169548,"top":0.53152436,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"2024-11-26 09:21:15","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"2024-11-26 09:21:15","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"2026-05-22 09:48:43","depth":6,"bounds":{"left":0.5169548,"top":0.55307263,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"2026-05-22 09:48:43","depth":7,"bounds":{"left":0.5169548,"top":0.55307263,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"2024-11-26 09:21:15","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"2024-11-26 09:21:15","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"2026-05-22 09:48:29","depth":6,"bounds":{"left":0.5169548,"top":0.5746209,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"2026-05-22 09:48:29","depth":7,"bounds":{"left":0.5169548,"top":0.5746209,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"2024-11-26 09:21:15","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"2024-11-26 09:21:15","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"2026-05-22 09:48:40","depth":6,"bounds":{"left":0.5169548,"top":0.5961692,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"2026-05-22 09:48:40","depth":7,"bounds":{"left":0.5169548,"top":0.5961692,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"2024-11-26 09:21:15","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"2024-11-26 09:21:15","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"<null>","depth":6,"bounds":{"left":0.5169548,"top":0.6177175,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"<null>","depth":7,"bounds":{"left":0.5169548,"top":0.6177175,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"2024-11-26 09:21:15","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"2024-11-26 09:21:15","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"<null>","depth":6,"bounds":{"left":0.5169548,"top":0.6392658,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"<null>","depth":7,"bounds":{"left":0.5169548,"top":0.6392658,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"2024-11-26 09:21:15","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"2024-11-26 09:21:15","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"{\"opportunity_sync_period\":\"3m\",\"opportunity_sync_strategies\":[\"lastModified\"]}","depth":6,"bounds":{"left":0.5169548,"top":0.66081405,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"{\"opportunity_sync_period\":\"3m\",\"opportunity_sync_strategies\":[\"lastModified\"]}","depth":7,"bounds":{"left":0.5169548,"top":0.66081405,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"{\"opportunity_sync_period\":\"3m\",\"opp","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"{\"opportunity_sync_period\":\"3m\",\"opp","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"open-recently-updated","depth":6,"bounds":{"left":0.5169548,"top":0.6823623,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"open-recently-updated","depth":7,"bounds":{"left":0.5169548,"top":0.6823623,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"}]...
|
7732332105235147404
|
-6520523286572803295
|
idle
|
accessibility
|
NULL
|
Project: faVsco.js, menu
JY-20963-fix-import-on-de Project: faVsco.js, menu
JY-20963-fix-import-on-deleted-entity, menu
Start Listening for PHP Debug Connections
ServiceTest
Rerun 'PHPUnit: ServiceTest'
Debug 'ServiceTest'
Stop 'ServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Built-in Preview
Chrome
Firefox
Safari
Sync Changes
Hide This Notification
Code changed:
Hide
4
32
168
1
28
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Tests\Unit\Services\Crm\Salesforce;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Events\Dispatcher;
use Illuminate\Support\Facades\Cache;
use Jiminny\Component\Country\CountriesMap;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Enums\CrmObject;
use Jiminny\Exceptions\InvalidArgumentException;
use Jiminny\Exceptions\NoResultsException;
use Jiminny\Integrations\PlaybookResolver;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Playbook;
use Jiminny\Models\Team;
use Jiminny\Models\TeamSettings;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\ContactRoleRepository;
use Jiminny\Repositories\Crm\FieldDataRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\Salesforce\Client;
use Jiminny\Services\Crm\Salesforce\PayloadBuilder;
use Jiminny\Services\Crm\Salesforce\QueryBuilder;
use Jiminny\Services\Crm\Salesforce\QueryHandler;
use Jiminny\Services\Crm\Salesforce\QueryIterator;
use Jiminny\Services\Crm\Salesforce\QueryResults;
use Jiminny\Services\Crm\Salesforce\Service;
use PHPUnit\Framework\MockObject\MockObject;
use Tests\TestCase;
use Tests\Unit\Traits\TestPrivateMethod;
class ServiceTest extends TestCase
{
use TestPrivateMethod;
public function testFetchAndAssociateRelatedActivity(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$payloadBuilder->method('addCustomLogicFieldsPayload')
->willReturnCallback(function ($activity, $payload) {
return $payload;
});
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods(['fetchRelatedActivity', 'getPlaybook', 'getPlaybookCategory', 'updateRecord'])
->getMock();
$serviceMock->expects($this->once())
->method('fetchRelatedActivity')
->willReturn([
'Id' => 'testId',
'Type' => null,
'OwnerId' => 'testerUser',
'Description' => 'Test description',
]);
$user = $this->createMock(User::class);
$team = $this->createMock(Team::class);
$user->method('getAttribute')->with('team')->willReturn($team);
$playbook = $this->createMock(Playbook::class);
$playbook->method('getActivityField')->willReturn(null);
$playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_EVENT);
$serviceMock->expects($this->once())
->method('getPlaybook')
->with($user)
->willReturn($playbook);
$serviceMock->expects($this->never())
->method('getPlaybookCategory');
$serviceMock->expects($this->never())
->method('updateRecord');
$fieldDataRepository = $this->createMock(FieldDataRepository::class);
$fieldDataRepository->method('getActivityFieldData')->willReturn(collect([]));
app()->instance(FieldDataRepository::class, $fieldDataRepository);
$config = $this->createMock(Configuration::class);
$profilesRelation = $this->getMockBuilder(\Illuminate\Database\Eloquent\Relations\HasMany::class)
->disableOriginalConstructor()
->onlyMethods(['get'])
->addMethods(['where'])
->getMock();
$profilesRelation->method('where')->willReturnSelf();
$profilesRelation->method('get')->willReturn(collect([]));
$config->method('profiles')->willReturn($profilesRelation);
$serviceMock->config = $config;
$serviceMock->profile = null;
$actualStartTime = \Carbon\Carbon::now();
$activity = $this->getMockBuilder(Activity::class)
->disableOriginalConstructor()
->onlyMethods(['update', 'hasProspect'])
->getMock();
$activity->method('update')->willReturn(true);
$activity->method('hasProspect')->willReturn(true);
$activity->type = Activity::TYPE_CONFERENCE;
$activity->provider = Activity::PROVIDER_TWILIO;
$activity->lead_id = 1;
$activity->user_id = 0;
$activity->id_string = 'test-activity-id';
$activity->user = $user;
$activity->actual_start_time = $actualStartTime;
$activity->uuid = 'c53d8320-f556-4cee-a2f8-5f232f454ca4';
app()->bind(PlaybookResolver::class, function () use ($user) {
$playbook = $this->createMock(Playbook::class);
$playbookResolver = $this->createMock(PlaybookResolver::class);
$playbookResolver->expects($this->once())
->method('resolvePlaybookByUser')
->with($user)
->willReturn($playbook);
return $playbookResolver;
});
$data = $serviceMock->fetchAndAssociateRelatedActivity($activity);
$this->assertInstanceOf(Activity::class, $data);
$this->assertEquals(Activity::TYPE_CONFERENCE, $data->getType());
$this->assertEquals($actualStartTime->getTimestamp(), $data->getActualStartTime()->getTimestamp());
}
public function testFetchAndAssociateRelatedActivitySkipsForTaskBasedPlaybook(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods(['fetchRelatedActivity', 'getPlaybook'])
->getMock();
$user = $this->createMock(User::class);
$playbook = $this->createMock(Playbook::class);
$playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);
$playbook->method('getId')->willReturn(123);
$serviceMock->expects($this->once())
->method('getPlaybook')
->with($user)
->willReturn($playbook);
$serviceMock->expects($this->never())
->method('fetchRelatedActivity');
$activity = $this->getMockBuilder(Activity::class)
->disableOriginalConstructor()
->onlyMethods(['hasProspect', 'getUuid'])
->getMock();
$activity->method('hasProspect')->willReturn(true);
$activity->method('getUuid')->willReturn('c53d8320-f556-4cee-a2f8-5f232f454ca4');
$activity->type = Activity::TYPE_CONFERENCE;
$activity->actual_start_time = \Carbon\Carbon::now();
$activity->user = $user;
$result = $serviceMock->fetchAndAssociateRelatedActivity($activity);
$this->assertNull($result);
}
public function testFetchAndAssociateRelatedActivityReturnsNullForNonConference(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$serviceMock = new Service(
client: $client,
payloadBuilder: $payloadBuilder,
eventDispatcher: $eventDispatcher,
countriesMap: $countriesMap,
prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)
);
$activity = $this->getMockBuilder(Activity::class)
->disableOriginalConstructor()
->getMock();
$activity->type = Activity::TYPE_SOFTPHONE;
$result = $serviceMock->fetchAndAssociateRelatedActivity($activity);
$this->assertNull($result);
}
public function testFetchAndAssociateRelatedActivityReturnsNullWhenNoStartTime(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$serviceMock = new Service(
client: $client,
payloadBuilder: $payloadBuilder,
eventDispatcher: $eventDispatcher,
countriesMap: $countriesMap,
prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)
);
$activity = $this->getMockBuilder(Activity::class)
->disableOriginalConstructor()
->getMock();
$activity->type = Activity::TYPE_CONFERENCE;
$activity->actual_start_time = null;
$activity->scheduled_start_time = null;
$result = $serviceMock->fetchAndAssociateRelatedActivity($activity);
$this->assertNull($result);
}
public function testFetchAndAssociateRelatedActivityReturnsNullWhenNoProspect(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods(['getPlaybook'])
->getMock();
$serviceMock->expects($this->never())
->method('getPlaybook');
$activity = $this->getMockBuilder(Activity::class)
->disableOriginalConstructor()
->onlyMethods(['hasProspect', 'getUuid'])
->getMock();
$activity->method('hasProspect')->willReturn(false);
$activity->method('getUuid')->willReturn('c53d8320-f556-4cee-a2f8-5f232f454ca4');
$activity->type = Activity::TYPE_CONFERENCE;
$activity->actual_start_time = \Carbon\Carbon::now();
$result = $serviceMock->fetchAndAssociateRelatedActivity($activity);
$this->assertNull($result);
}
public function testMatchExactlyByEmail(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods([])
->getMock();
$profile = new Profile();
$profile->setAttribute('id', bin2hex(random_bytes(8)));
$serviceMock->profile = $profile;
$team = $this->createMock(Team::class);
$serviceMock->team = $team;
$data = $serviceMock->matchExactlyByEmail(bin2hex(random_bytes(8)) . '[EMAIL]');
$this->assertEquals(null, $data);
}
public function testMatchDomainFromEmail(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$queryIterator = $this->createMock(QueryIterator::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$config = $this->createMock(Configuration::class);
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('search')
->willReturn($queryIterator);
return $handler;
});
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods(['convertCrmData'])
->getMock();
$profile = new Profile();
$profile->account_fields = 'Field1, Field2, Field3';
$serviceMock->profile = $profile;
$serviceMock->expects($this->once())
->method('convertCrmData')
->willReturn(['test']);
$this->app->bind(QueryBuilder::class, function () {
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->expects($this->once())
->method('buildMatchByDomainQuery')
->with('[EMAIL]')
->willReturn('FIND {[EMAIL]} IN ALL FIELDS RETURNING Account(Id)');
return $queryBuilder;
});
$team = $this->createMock(Team::class);
$serviceMock->team = $team;
$serviceMock->config = $config;
$data = $serviceMock->matchByDomain('[EMAIL]');
$this->assertEquals(['test'], $data);
}
public function testBuildTaskSearchFields(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$service = new Service(
client: $client,
payloadBuilder: $payloadBuilder,
eventDispatcher: $eventDispatcher,
countriesMap: $countriesMap,
prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)
);
$fields = $service->buildTaskSearchFields();
$expectedFields = ['Id', 'WhoId', 'WhatId', 'AccountId'];
$this->assertEquals($expectedFields, $fields);
}
public function testMapCrmObjects(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$service = new Service(
client: $client,
payloadBuilder: $payloadBuilder,
eventDispatcher: $eventDispatcher,
countriesMap: $countriesMap,
prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class),
);
$sampleTask = [
'WhoId' => '003sampleWhoId',
'AccountId' => 'sampleAccountId',
'WhatId' => 'sampleWhatId',
];
$activityData = $service->mapCrmObjects($sampleTask);
$expectedActivityData = [
'contact' => '003sampleWhoId',
'account' => 'sampleAccountId',
'opportunity' => 'sampleWhatId',
];
$this->assertEquals($expectedActivityData, $activityData);
}
public function testGetInstalledAppVersion(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$queryIterator = $this->createMock(QueryIterator::class);
$queryIterator->expects($this->any())
->method('current')->willReturn([
'SubscriberPackageVersion' => [
'MajorVersion' => '1',
'MinorVersion' => '0',
'PatchVersion' => '1',
'BuildNumber' => '0',
],
]);
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('metadata')
->willReturn($queryIterator);
return $handler;
});
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods(array_diff(get_class_methods(Service::class), ['getInstalledAppVersion']))
->getMock();
$version = $serviceMock->getInstalledAppVersion();
$this->assertEquals('1010', $version);
}
public function testSyncProfiles(): void
{
$userToSearch = null;
$team = $this->createMock(Team::class);
$config = $this->createMockedConfiguration();
$config->expects($this->once())
->method('getId')
->willReturn(1);
$salesforceUser = [
'Email' => '[EMAIL]',
'UserPreferencesLightningExperiencePreferred' => true,
'CallCenterId' => '123',
'Id' => '456',
'ProfileId' => '789',
];
app()->bind(QueryBuilder::class, function () use ($userToSearch) {
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->expects($this->once())
->method('buildGetUsersQuery')
->with($userToSearch)
->willReturn('SELECT * FROM Users');
return $queryBuilder;
});
$queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('query')
->with('SELECT * FROM Users')
->willReturn($queryIterator);
return $handler;
});
$user = $this->createMock(User::class);
$user->expects($this->once())
->method('getId')
->willReturn(123);
$this->mockTeamRepository($team, $salesforceUser, $user);
$profileRepository = $this->createMock(ProfileRepository::class);
$profileRepository->expects($this->once())
->method('updateOrCreateProfile')
->with(
$user,
[
'crm_configuration_id' => 1,
'crm_provider_id' => '456',
],
[
'user_id' => 123,
'edition' => Profile::EDITION_LIGHTNING,
'has_external_cti' => true,
'crm_profile_id' => '789',
]
)
->willReturn(new Profile());
$this->app->instance(ProfileRepository::class, $profileRepository);
$serviceMock = $this->getServiceMock();
$serviceMock->team = $team;
$serviceMock->config = $config;
$result = $serviceMock->syncProfiles($userToSearch);
$this->assertNull($result);
}
public function testSyncProfilesEmailIsNull(): void
{
$userToSearch = $this->createMock(User::class);
$salesforceUser = [
'Email' => null,
];
app()->bind(QueryBuilder::class, function () {
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->expects($this->once())
->method('buildGetUsersQuery')
->with(null)
->willReturn('SELECT * FROM Users');
return $queryBuilder;
});
$queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->exactly(2))
->method('query')
->willReturn($queryIterator);
return $handler;
});
$team = $this->createMock(Team::class);
$user = $this->createMock(User::class);
$this->mockTeamRepository($team, $salesforceUser, $user, false);
$config = $this->createMock(Configuration::class);
$serviceMock = $this->getServiceMock();
$serviceMock->team = $team;
$serviceMock->config = $config;
$profile = $serviceMock->syncProfiles(null);
$this->assertNull($profile);
}
public function testSyncProfilesUserToSearchMatchesCurrentUser(): void
{
$userToSearch = $this->createMock(User::class);
$userToSearch->expects($this->once())
->method('getId')
->willReturn(123);
$team = $this->createMock(Team::class);
$config = $this->createMock(Configuration::class);
$config->expects($this->once())
->method('getId')
->willReturn(1);
$salesforceUser = [
'Email' => '[EMAIL]',
'UserPreferencesLightningExperiencePreferred' => true,
'CallCenterId' => '123',
'Id' => '456',
'ProfileId' => '789',
];
app()->bind(QueryBuilder::class, function () use ($userToSearch) {
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->expects($this->once())
->method('buildGetUsersQuery')
->with($userToSearch)
->willReturn('SELECT * FROM Users');
return $queryBuilder;
});
$queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('query')
->with('SELECT * FROM Users')
->willReturn($queryIterator);
return $handler;
});
$user = $this->createMock(User::class);
$user->expects($this->exactly(2))
->method('getId')
->willReturn(123);
$this->mockTeamRepository($team, $salesforceUser, $user);
$profileRepository = $this->createMock(ProfileRepository::class);
$profileRepository->expects($this->once())
->method('updateOrCreateProfile')
->with(
$user,
[
'crm_configuration_id' => 1,
'crm_provider_id' => '456',
],
[
'user_id' => 123,
'edition' => Profile::EDITION_LIGHTNING,
'has_external_cti' => true,
'crm_profile_id' => '789',
]
)
->willReturn(new Profile());
$this->app->instance(ProfileRepository::class, $profileRepository);
$serviceMock = $this->getServiceMock();
$serviceMock->team = $team;
$serviceMock->config = $config;
$profile = $serviceMock->syncProfiles($userToSearch);
$this->assertInstanceOf(Profile::class, $profile);
}
public function testSyncProfilesWithCustomValidation(): void
{
$userToSearch = null;
$team = $this->createMock(Team::class);
$config = $this->createMockedConfiguration();
$config->expects($this->atLeastOnce()) // Changed from once() to atLeastOnce()
->method('getId')
->willReturn(1);
$salesforceUser = [
'Email' => '[EMAIL]',
'UserPreferencesLightningExperiencePreferred' => true,
'CallCenterId' => '123',
'Id' => '456',
'ProfileId' => '789',
'CustomField' => 'CustomValue',
];
$customRules = [
['field' => 'CustomField', 'value' => 'CustomValue'],
];
$this->mockQueryBuilderAndHandler($userToSearch, [$salesforceUser]);
$user = $this->createMock(User::class);
$user->expects($this->once())
->method('getId')
->willReturn(123);
$this->mockTeamRepository($team, $salesforceUser, $user, true, $customRules);
$this->mockProfileRepository($user);
$serviceMock = $this->getServiceMock();
$serviceMock->team = $team;
$serviceMock->config = $config;
$profile = $serviceMock->syncProfiles($userToSearch);
$this->assertNull($profile);
}
public function testSyncProfilesWithCustomValidationFailing(): void
{
$userToSearch = null;
$team = $this->createMock(Team::class);
$config = $this->createMockedConfiguration();
$salesforceUser = [
'Email' => '[EMAIL]',
'UserPreferencesLightningExperiencePreferred' => true,
'CallCenterId' => '123',
'Id' => '456',
'ProfileId' => '789',
'CustomField' => 'WrongValue',
];
$customRules = [
['field' => 'CustomField', 'value' => 'CustomValue'],
];
$this->mockQueryBuilderAndHandler($userToSearch, [$salesforceUser]);
$teamRepository = $this->getMockForAbstractClass(TeamRepository::class, [], '', false, true, true, ['findActiveTeamMemberByEmail', 'getTeamSetting']);
$teamSettings = $this->createMock(TeamSettings::class);
$teamSettings->method('getValueType')
->willReturn('array');
$teamSettings->method('getValue')
->willReturn(json_encode($customRules));
$teamRepository->expects($this->once())
->method('getTeamSetting')
->with($team, 'custom_profile_validation')
->willReturn($teamSettings);
app()->bind(TeamRepository::class, function () use ($teamRepository) {
return $teamRepository;
});
$profileRepository = $this->createMock(ProfileRepository::class);
$profileRepository->expects($this->never())
->method('updateOrCreateProfile');
$this->app->instance(ProfileRepository::class, $profileRepository);
$serviceMock = $this->getServiceMock();
$serviceMock->team = $team;
$serviceMock->config = $config;
$result = $serviceMock->syncProfiles($userToSearch);
$this->assertNull($result);
}
public function testGetContactRolesFromCrm(): void
{
$contactRoles = [
[
'Id' => '1',
'ContactId' => 'Contact1',
'OpportunityId' => 'Opportunity1',
'Opportunity' => ['OwnerId' => 'Owner1'],
'IsPrimary' => true,
'Role' => 'Decision Maker',
],
];
$expectedResponse = [
[
'id' => '1',
'contactId' => 'Contact1',
'opportunityId' => 'Opportunity1',
'ownerId' => 'Owner1',
'isPrimary' => true,
'role' => 'Decision Maker',
],
];
$this->bindQueryIterator($contactRoles);
$serviceMock = $this->getServiceMock();
$data = $serviceMock->getContactRolesFromCrm(now()->subDay());
$this->assertEquals($expectedResponse, $data);
}
public function testGetContactRolesFromCrmNoResult(): void
{
$this->bindQueryIterator([]);
$serviceMock = $this->getServiceMock();
$data = $serviceMock->getContactRolesFromCrm(now()->subDay());
$this->assertEquals([], $data);
}
public function testSyncContactRoles(): void
{
$contactRoles = [
[
'id' => '1',
'contactId' => 'Contact1',
'opportunityId' => 'Opportunity1',
'ownerId' => 'Owner1',
'isPrimary' => true,
'role' => 'Decision Maker',
],
];
app()->bind(ContactRoleRepository::class, function () {
$contactRoleRepository = $this->createMock(ContactRoleRepository::class);
$contactRoleRepository->expects($this->once())
->method('saveContactRoles');
return $contactRoleRepository;
});
$serviceMock = $this->getServiceMock([
'getContactRolesFromCrm',
'syncRemotelyDeletedContactRoles',
'syncContact',
'syncOpportunity',
]);
$config = $this->createMock(Configuration::class);
$hasMany = $this->createMock(HasManyExtended::class);
$hasMany->expects($this->exactly(2))
->method('where')
->willReturn($hasMany);
$hasMany->expects($this->exactly(2))
->method('first')
->willReturn(
$this->createMock(Contact::class),
$this->createMock(Opportunity::class)
);
$config->expects($this->once())
->method('contacts')
->willReturn($hasMany);
$config->expects($this->once())
->method('opportunities')
->willReturn($hasMany);
$serviceMock->config = $config;
$serviceMock->expects($this->once())
->method('getContactRolesFromCrm')
->willReturn($contactRoles);
$serviceMock->expects($this->once())
->method('syncRemotelyDeletedContactRoles');
$data = $serviceMock->syncContactRoles(now()->subDay());
$this->assertEquals(1, $data);
}
public function testSyncRemotelyDeletedContactRoles(): void
{
$contactRoles = [
[
'id' => '1',
'crm_provider_id' => '1',
],
];
app()->bind(QueryHandler::class, function () use ($contactRoles) {
$queryResults = new QueryResults($contactRoles, 1, true, null);
$handler = $this->createMock(QueryHandler::class);
$handler->method('queryDeleted')
->willReturn($queryResults);
return $handler;
});
app()->bind(ContactRoleRepository::class, function () {
$contactRoleRepository = $this->createMock(ContactRoleRepository::class);
$contactRoleRepository->expects($this->once())
->method('deleteContactRoles');
return $contactRoleRepository;
});
$serviceMock = $this->getServiceMock();
$serviceMock->team = $this->createMock(Team::class);
$data = $this->invokePrivateMethod('syncRemotelyDeletedContactRoles', $serviceMock, []);
$this->assertTrue($data);
}
private function bindQueryIterator(array $queryResult): void
{
/** @var Client $client */
$client = $this->createMock(Client::class);
$queryIterator = new QueryIterator(
$client,
new QueryResults($queryResult, 1, true, null)
);
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('query')
->willReturn($queryIterator);
return $handler;
});
}
public static function getOpportunitySortOrderDataProvider(): array
{
return [
'all open recently updated' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED, ['LastModifiedDate DESC', true]],
'all open recently created' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED, ['CreatedDate DESC', true]],
'all open oldest created' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED, ['CreatedDate ASC', true]],
'all recently updated' => [Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED, ['LastModifiedDate DESC', false]],
'default' => ['unknown', ['LastModifiedDate DESC', true]],
];
}
private function createMockedConfiguration(): Configuration
{
$config = $this->createMock(Configuration::class);
$profilesRelation = $this->getMockBuilder(\Illuminate\Database\Eloquent\Relations\HasMany::class)
->disableOriginalConstructor()
->onlyMethods(['get'])
->addMethods(['where', 'first'])
->getMock();
$profilesRelation->method('where')->willReturnSelf();
$profilesRelation->method('get')->willReturn(collect([]));
$profilesRelation->method('first')->willReturn(null);
$config->method('profiles')->willReturn($profilesRelation);
return $config;
}
private function getServiceMock(array $onlyMethods = []): MockObject&Service
{
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$this->createMock(Client::class),
$this->createMock(PayloadBuilder::class),
$this->createMock(Dispatcher::class),
$this->createMock(CountriesMap::class),
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods($onlyMethods)
->getMock();
$serviceMock->profile = $this->createMock(Profile::class);
return $serviceMock;
}
private function mockQueryBuilderAndHandler($userToSearch, $salesforceUsers): void
{
app()->bind(QueryBuilder::class, function () use ($userToSearch) {
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->expects($this->once())
->method('buildGetUsersQuery')
->with($userToSearch)
->willReturn('SELECT * FROM Users');
return $queryBuilder;
});
$queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults($salesforceUsers, count($salesforceUsers), true, null));
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('query')
->willReturn($queryIterator);
return $handler;
});
}
private function mockTeamRepository(
Team $team,
array $salesforceUser,
?User $user = null,
bool $userSearch = true,
array $customRules = []
): void {
$teamRepository = $this->getMockForAbstractClass(TeamRepository::class, [], '', false, true, true, ['findActiveTeamMemberByEmail', 'getTeamSetting']);
if ($userSearch) {
$teamRepository->expects($this->once())
->method('findActiveTeamMemberByEmail')
->with($team, $salesforceUser['Email'])
->willReturn($user);
}
$teamSettings = $this->createMock(TeamSettings::class);
$teamSettings->method('getValueType')
->willReturn('array');
$teamSettings->method('getValue')
->willReturn(json_encode($customRules));
$teamRepository->expects($this->once())
->method('getTeamSetting')
->with($team, 'custom_profile_validation')
->willReturn($teamSettings);
app()->bind(TeamRepository::class, function () use ($teamRepository) {
return $teamRepository;
});
}
private function mockProfileRepository(User $user): void
{
$profileRepository = $this->createMock(ProfileRepository::class);
$profileRepository->expects($this->once())
->method('updateOrCreateProfile')
->with(
$user,
[
'crm_configuration_id' => 1,
'crm_provider_id' => '456',
],
[
'user_id' => 123,
'edition' => Profile::EDITION_LIGHTNING,
'has_external_cti' => true,
'crm_profile_id' => '789',
]
)
->willReturn(new Profile());
$this->app->instance(ProfileRepository::class, $profileRepository);
}
public function testBuildEnhancedNoteDecodesHtmlEntities(): void
{
$service = $this->getServiceMock(['createRecord']);
$profile = new Profile();
$profile->setAttribute('crm_provider_id', 'owner-123');
$service->profile = $profile;
$service->expects($this->exactly(2))
->method('createRecord')
->willReturnOnConsecutiveCalls('note-id-123', 'link-id-456');
$bodyWithEntities = 'Welch's current challenges and Facebook's Club';
$result = $this->invokePrivateMethod('buildEnhancedNote', $service, [
'Test Title',
$bodyWithEntities,
'object-id-789',
]);
$this->assertEquals('note-id-123', $result);
}
public function testBuildEnhancedNoteSanitizesWithoutQuotes(): void
{
$service = $this->getServiceMock(['createRecord']);
$profile = new Profile();
$profile->setAttribute('crm_provider_id', 'owner-456');
$service->profile = $profile;
$service->expects($this->exactly(2))
->method('createRecord')
->willReturnCallback(function ($type, $data) {
if ($type === 'ContentNote') {
$decoded = base64_decode($data['Content']);
$this->assertStringContainsString("Welch's", $decoded);
$this->assertStringNotContainsString(''', $decoded);
$this->assertStringNotContainsString('&#039;', $decoded);
$this->assertStringContainsString('<script>', $decoded);
return 'note-id-456';
}
return 'link-id-789';
});
$bodyWithMixedContent = "Welch's and <script>alert('xss')</script>";
$result = $this->invokePrivateMethod('buildEnhancedNote', $service, [
'Test Title',
$bodyWithMixedContent,
'object-id-123',
]);
$this->assertEquals('note-id-456', $result);
}
public function testBuildEnhancedNoteConvertsLineBreaks(): void
{
$service = $this->getServiceMock(['createRecord']);
$profile = new Profile();
$profile->setAttribute('crm_provider_id', 'owner-789');
$service->profile = $profile;
$service->expects($this->exactly(2))
->method('createRecord')
->willReturnCallback(function ($type, $data) {
if ($type === 'ContentNote') {
$decoded = base64_decode($data['Content']);
$this->assertStringContainsString('<br>', $decoded);
$this->assertStringNotContainsString('<br />', $decoded);
return 'note-id-789';
}
return 'link-id-012';
});
$bodyWithLineBreaks = "Line 1\nLine 2\nLine 3";
$result = $this->invokePrivateMethod('buildEnhancedNote', $service, [
'Test Title',
$bodyWithLineBreaks,
'object-id-456',
]);
$this->assertEquals('note-id-789', $result);
}
public function testBuildEnhancedNoteHandlesComplexScenario(): void
{
$service = $this->getServiceMock(['createRecord']);
$profile = new Profile();
$profile->setAttribute('crm_provider_id', 'owner-complex');
$service->profile = $profile;
$service->expects($this->exactly(2))
->method('createRecord')
->willReturnCallback(function ($type, $data) {
if ($type === 'ContentNote') {
$decoded = base64_decode($data['Content']);
$this->assertStringContainsString("Welch's", $decoded);
$this->assertStringContainsString("Facebook's Club", $decoded);
$this->assertStringContainsString("Arctics'", $decoded);
$this->assertStringNotContainsString(''', $decoded);
$this->assertStringNotContainsString('&#039;', $decoded);
$this->assertStringContainsString('<br>', $decoded);
$this->assertStringContainsString('<', $decoded);
$this->assertStringContainsString('>', $decoded);
return 'note-complex';
}
return 'link-complex';
});
$complexBody = "Summary:\n---------\nThe call focused on understanding Welch's current challenges and exploring how Arctics' Revenue Growth Management solutions could support their strategic goals.\n\n• John SMith discussed his role as a category advisor for Google and Facebook's Club, emphasizing the importance of market research and advising on product assortment.\n• Madona introduced Arctics' Virtual Shoppers AI, which simulates consumer <behavior> to optimize pricing and promotional strategies.";
$result = $this->invokePrivateMethod('buildEnhancedNote', $service, [
'Jiminny Transcription Summary',
$complexBody,
'task-id-001',
]);
$this->assertEquals('note-complex', $result);
}
public function testSyncRemotelyDeletedObjectsWithErrorHandlingSuccess(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$prospectPhotoPathService,
])
->onlyMethods(['syncRemotelyDeletedObjects'])
->getMock();
// Mock team
$team = $this->createMock(Team::class);
$team->method('getUuid')->willReturn('team-uuid-123');
$serviceMock->team = $team;
// Expect syncRemotelyDeletedObjects to be called once and succeed
$serviceMock->expects($this->once())
->method('syncRemotelyDeletedObjects')
->with(\Jiminny\Enums\CrmObject::ACCOUNT);
// Call the protected method using reflection
$reflection = new \ReflectionClass($serviceMock);
$method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');
$method->setAccessible(true);
// Should not throw any exceptions
$method->invoke($serviceMock, \Jiminny\Enums\CrmObject::ACCOUNT);
$this->assertTrue(true); // Test completed successfully
}
public function testSyncRemotelyDeletedObjectsWithErrorHandlingFailure(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$prospectPhotoPathService,
])
->onlyMethods(['syncRemotelyDeletedObjects'])
->getMock();
// Mock team
$team = $this->createMock(Team::class);
$team->method('getUuid')->willReturn('team-uuid-456');
$serviceMock->team = $team;
// Mock logger to verify warning is logged
$logger = $this->createMock(\Psr\Log\LoggerInterface::class);
// Use reflection to set the protected logger property
$reflection = new \ReflectionClass($serviceMock);
$loggerProperty = $reflection->getProperty('logger');
$loggerProperty->setAccessible(true);
$loggerProperty->setValue($serviceMock, $logger);
$exception = new \Exception('Sync failed due to API error');
// Expect syncRemotelyDeletedObjects to throw an exception
$serviceMock->expects($this->once())
->method('syncRemotelyDeletedObjects')
->with(\Jiminny\Enums\CrmObject::CONTACT)
->willThrowException($exception);
// Expect warning to be logged with correct message and parameters
$logger->expects($this->once())
->method('warning')
->with(
'[Salesforce] Remotely deleted objects sync failed',
[
'objectType' => 'contact',
'teamId' => 'team-uuid-456',
'reason' => 'Sync failed due to API error',
]
);
// Call the protected method using reflection
$reflection = new \ReflectionClass($serviceMock);
$method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');
$method->setAccessible(true);
// Should not re-throw the exception, just log it
$method->invoke($serviceMock, \Jiminny\Enums\CrmObject::CONTACT);
$this->assertTrue(true); // Test completed successfully
}
public function testSyncRemotelyDeletedObjectsWithErrorHandlingWithLogParams(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$prospectPhotoPathService,
])
->onlyMethods(['syncRemotelyDeletedObjects'])
->getMock();
// Mock team
$team = $this->createMock(Team::class);
$team->method('getUuid')->willReturn('team-uuid-789');
$serviceMock->team = $team;
// Mock logger to verify warning is logged
$logger = $this->createMock(\Psr\Log\LoggerInterface::class);
// Use reflection to set the protected logger property
$loggerReflection = new \ReflectionClass($serviceMock);
$loggerProperty = $loggerReflection->getProperty('logger');
$loggerProperty->setAccessible(true);
$loggerProperty->setValue($serviceMock, $logger);
$exception = new \Exception('Network timeout');
// Expect syncRemotelyDeletedObjects to throw an exception
$serviceMock->expects($this->once())
->method('syncRemotelyDeletedObjects')
->with(\Jiminny\Enums\CrmObject::OPPORTUNITY)
->willThrowException($exception);
// Additional log parameters
$logParams = [
'syncType' => 'full',
'batchSize' => 100,
];
// Expect warning to be logged with merged parameters
$logger->expects($this->once())
->method('warning')
->with(
'[Salesforce] Remotely deleted objects sync failed',
[
'objectType' => 'opportunity',
'teamId' => 'team-uuid-789',
'reason' => 'Network timeout',
'syncType' => 'full',
'batchSize' => 100,
]
);
// Call the protected method using reflection
$reflection = new \ReflectionClass($serviceMock);
$method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');
...
|
70622
|
NULL
|
NULL
|
NULL
|
|
70623
|
2536
|
4
|
2026-05-22T10:23:53.585506+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779445433585_m1.jpg...
|
PhpStorm
|
faVsco.js – ServiceTest.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20963-fix-import-on-de Project: faVsco.js, menu
JY-20963-fix-import-on-deleted-entity, menu
Start Listening for PHP Debug Connections
ServiceTest
Rerun 'PHPUnit: ServiceTest'
Debug 'ServiceTest'
Stop 'ServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Built-in Preview
Chrome
Firefox
Safari
Sync Changes
Hide This Notification
Code changed:
Hide
4
32
168
1
28
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Tests\Unit\Services\Crm\Salesforce;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Events\Dispatcher;
use Illuminate\Support\Facades\Cache;
use Jiminny\Component\Country\CountriesMap;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Enums\CrmObject;
use Jiminny\Exceptions\InvalidArgumentException;
use Jiminny\Exceptions\NoResultsException;
use Jiminny\Integrations\PlaybookResolver;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Playbook;
use Jiminny\Models\Team;
use Jiminny\Models\TeamSettings;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\ContactRoleRepository;
use Jiminny\Repositories\Crm\FieldDataRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\Salesforce\Client;
use Jiminny\Services\Crm\Salesforce\PayloadBuilder;
use Jiminny\Services\Crm\Salesforce\QueryBuilder;
use Jiminny\Services\Crm\Salesforce\QueryHandler;
use Jiminny\Services\Crm\Salesforce\QueryIterator;
use Jiminny\Services\Crm\Salesforce\QueryResults;
use Jiminny\Services\Crm\Salesforce\Service;
use PHPUnit\Framework\MockObject\MockObject;
use Tests\TestCase;
use Tests\Unit\Traits\TestPrivateMethod;
class ServiceTest extends TestCase
{
use TestPrivateMethod;
public function testFetchAndAssociateRelatedActivity(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$payloadBuilder->method('addCustomLogicFieldsPayload')
->willReturnCallback(function ($activity, $payload) {
return $payload;
});
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods(['fetchRelatedActivity', 'getPlaybook', 'getPlaybookCategory', 'updateRecord'])
->getMock();
$serviceMock->expects($this->once())
->method('fetchRelatedActivity')
->willReturn([
'Id' => 'testId',
'Type' => null,
'OwnerId' => 'testerUser',
'Description' => 'Test description',
]);
$user = $this->createMock(User::class);
$team = $this->createMock(Team::class);
$user->method('getAttribute')->with('team')->willReturn($team);
$playbook = $this->createMock(Playbook::class);
$playbook->method('getActivityField')->willReturn(null);
$playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_EVENT);
$serviceMock->expects($this->once())
->method('getPlaybook')
->with($user)
->willReturn($playbook);
$serviceMock->expects($this->never())
->method('getPlaybookCategory');
$serviceMock->expects($this->never())
->method('updateRecord');
$fieldDataRepository = $this->createMock(FieldDataRepository::class);
$fieldDataRepository->method('getActivityFieldData')->willReturn(collect([]));
app()->instance(FieldDataRepository::class, $fieldDataRepository);
$config = $this->createMock(Configuration::class);
$profilesRelation = $this->getMockBuilder(\Illuminate\Database\Eloquent\Relations\HasMany::class)
->disableOriginalConstructor()
->onlyMethods(['get'])
->addMethods(['where'])
->getMock();
$profilesRelation->method('where')->willReturnSelf();
$profilesRelation->method('get')->willReturn(collect([]));
$config->method('profiles')->willReturn($profilesRelation);
$serviceMock->config = $config;
$serviceMock->profile = null;
$actualStartTime = \Carbon\Carbon::now();
$activity = $this->getMockBuilder(Activity::class)
->disableOriginalConstructor()
->onlyMethods(['update', 'hasProspect'])
->getMock();
$activity->method('update')->willReturn(true);
$activity->method('hasProspect')->willReturn(true);
$activity->type = Activity::TYPE_CONFERENCE;
$activity->provider = Activity::PROVIDER_TWILIO;
$activity->lead_id = 1;
$activity->user_id = 0;
$activity->id_string = 'test-activity-id';
$activity->user = $user;
$activity->actual_start_time = $actualStartTime;
$activity->uuid = 'c53d8320-f556-4cee-a2f8-5f232f454ca4';
app()->bind(PlaybookResolver::class, function () use ($user) {
$playbook = $this->createMock(Playbook::class);
$playbookResolver = $this->createMock(PlaybookResolver::class);
$playbookResolver->expects($this->once())
->method('resolvePlaybookByUser')
->with($user)
->willReturn($playbook);
return $playbookResolver;
});
$data = $serviceMock->fetchAndAssociateRelatedActivity($activity);
$this->assertInstanceOf(Activity::class, $data);
$this->assertEquals(Activity::TYPE_CONFERENCE, $data->getType());
$this->assertEquals($actualStartTime->getTimestamp(), $data->getActualStartTime()->getTimestamp());
}
public function testFetchAndAssociateRelatedActivitySkipsForTaskBasedPlaybook(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods(['fetchRelatedActivity', 'getPlaybook'])
->getMock();
$user = $this->createMock(User::class);
$playbook = $this->createMock(Playbook::class);
$playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);
$playbook->method('getId')->willReturn(123);
$serviceMock->expects($this->once())
->method('getPlaybook')
->with($user)
->willReturn($playbook);
$serviceMock->expects($this->never())
->method('fetchRelatedActivity');
$activity = $this->getMockBuilder(Activity::class)
->disableOriginalConstructor()
->onlyMethods(['hasProspect', 'getUuid'])
->getMock();
$activity->method('hasProspect')->willReturn(true);
$activity->method('getUuid')->willReturn('c53d8320-f556-4cee-a2f8-5f232f454ca4');
$activity->type = Activity::TYPE_CONFERENCE;
$activity->actual_start_time = \Carbon\Carbon::now();
$activity->user = $user;
$result = $serviceMock->fetchAndAssociateRelatedActivity($activity);
$this->assertNull($result);
}
public function testFetchAndAssociateRelatedActivityReturnsNullForNonConference(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$serviceMock = new Service(
client: $client,
payloadBuilder: $payloadBuilder,
eventDispatcher: $eventDispatcher,
countriesMap: $countriesMap,
prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)
);
$activity = $this->getMockBuilder(Activity::class)
->disableOriginalConstructor()
->getMock();
$activity->type = Activity::TYPE_SOFTPHONE;
$result = $serviceMock->fetchAndAssociateRelatedActivity($activity);
$this->assertNull($result);
}
public function testFetchAndAssociateRelatedActivityReturnsNullWhenNoStartTime(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$serviceMock = new Service(
client: $client,
payloadBuilder: $payloadBuilder,
eventDispatcher: $eventDispatcher,
countriesMap: $countriesMap,
prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)
);
$activity = $this->getMockBuilder(Activity::class)
->disableOriginalConstructor()
->getMock();
$activity->type = Activity::TYPE_CONFERENCE;
$activity->actual_start_time = null;
$activity->scheduled_start_time = null;
$result = $serviceMock->fetchAndAssociateRelatedActivity($activity);
$this->assertNull($result);
}
public function testFetchAndAssociateRelatedActivityReturnsNullWhenNoProspect(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods(['getPlaybook'])
->getMock();
$serviceMock->expects($this->never())
->method('getPlaybook');
$activity = $this->getMockBuilder(Activity::class)
->disableOriginalConstructor()
->onlyMethods(['hasProspect', 'getUuid'])
->getMock();
$activity->method('hasProspect')->willReturn(false);
$activity->method('getUuid')->willReturn('c53d8320-f556-4cee-a2f8-5f232f454ca4');
$activity->type = Activity::TYPE_CONFERENCE;
$activity->actual_start_time = \Carbon\Carbon::now();
$result = $serviceMock->fetchAndAssociateRelatedActivity($activity);
$this->assertNull($result);
}
public function testMatchExactlyByEmail(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods([])
->getMock();
$profile = new Profile();
$profile->setAttribute('id', bin2hex(random_bytes(8)));
$serviceMock->profile = $profile;
$team = $this->createMock(Team::class);
$serviceMock->team = $team;
$data = $serviceMock->matchExactlyByEmail(bin2hex(random_bytes(8)) . '[EMAIL]');
$this->assertEquals(null, $data);
}
public function testMatchDomainFromEmail(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$queryIterator = $this->createMock(QueryIterator::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$config = $this->createMock(Configuration::class);
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('search')
->willReturn($queryIterator);
return $handler;
});
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods(['convertCrmData'])
->getMock();
$profile = new Profile();
$profile->account_fields = 'Field1, Field2, Field3';
$serviceMock->profile = $profile;
$serviceMock->expects($this->once())
->method('convertCrmData')
->willReturn(['test']);
$this->app->bind(QueryBuilder::class, function () {
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->expects($this->once())
->method('buildMatchByDomainQuery')
->with('[EMAIL]')
->willReturn('FIND {[EMAIL]} IN ALL FIELDS RETURNING Account(Id)');
return $queryBuilder;
});
$team = $this->createMock(Team::class);
$serviceMock->team = $team;
$serviceMock->config = $config;
$data = $serviceMock->matchByDomain('[EMAIL]');
$this->assertEquals(['test'], $data);
}
public function testBuildTaskSearchFields(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$service = new Service(
client: $client,
payloadBuilder: $payloadBuilder,
eventDispatcher: $eventDispatcher,
countriesMap: $countriesMap,
prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)
);
$fields = $service->buildTaskSearchFields();
$expectedFields = ['Id', 'WhoId', 'WhatId', 'AccountId'];
$this->assertEquals($expectedFields, $fields);
}
public function testMapCrmObjects(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$service = new Service(
client: $client,
payloadBuilder: $payloadBuilder,
eventDispatcher: $eventDispatcher,
countriesMap: $countriesMap,
prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class),
);
$sampleTask = [
'WhoId' => '003sampleWhoId',
'AccountId' => 'sampleAccountId',
'WhatId' => 'sampleWhatId',
];
$activityData = $service->mapCrmObjects($sampleTask);
$expectedActivityData = [
'contact' => '003sampleWhoId',
'account' => 'sampleAccountId',
'opportunity' => 'sampleWhatId',
];
$this->assertEquals($expectedActivityData, $activityData);
}
public function testGetInstalledAppVersion(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$queryIterator = $this->createMock(QueryIterator::class);
$queryIterator->expects($this->any())
->method('current')->willReturn([
'SubscriberPackageVersion' => [
'MajorVersion' => '1',
'MinorVersion' => '0',
'PatchVersion' => '1',
'BuildNumber' => '0',
],
]);
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('metadata')
->willReturn($queryIterator);
return $handler;
});
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods(array_diff(get_class_methods(Service::class), ['getInstalledAppVersion']))
->getMock();
$version = $serviceMock->getInstalledAppVersion();
$this->assertEquals('1010', $version);
}
public function testSyncProfiles(): void
{
$userToSearch = null;
$team = $this->createMock(Team::class);
$config = $this->createMockedConfiguration();
$config->expects($this->once())
->method('getId')
->willReturn(1);
$salesforceUser = [
'Email' => '[EMAIL]',
'UserPreferencesLightningExperiencePreferred' => true,
'CallCenterId' => '123',
'Id' => '456',
'ProfileId' => '789',
];
app()->bind(QueryBuilder::class, function () use ($userToSearch) {
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->expects($this->once())
->method('buildGetUsersQuery')
->with($userToSearch)
->willReturn('SELECT * FROM Users');
return $queryBuilder;
});
$queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('query')
->with('SELECT * FROM Users')
->willReturn($queryIterator);
return $handler;
});
$user = $this->createMock(User::class);
$user->expects($this->once())
->method('getId')
->willReturn(123);
$this->mockTeamRepository($team, $salesforceUser, $user);
$profileRepository = $this->createMock(ProfileRepository::class);
$profileRepository->expects($this->once())
->method('updateOrCreateProfile')
->with(
$user,
[
'crm_configuration_id' => 1,
'crm_provider_id' => '456',
],
[
'user_id' => 123,
'edition' => Profile::EDITION_LIGHTNING,
'has_external_cti' => true,
'crm_profile_id' => '789',
]
)
->willReturn(new Profile());
$this->app->instance(ProfileRepository::class, $profileRepository);
$serviceMock = $this->getServiceMock();
$serviceMock->team = $team;
$serviceMock->config = $config;
$result = $serviceMock->syncProfiles($userToSearch);
$this->assertNull($result);
}
public function testSyncProfilesEmailIsNull(): void
{
$userToSearch = $this->createMock(User::class);
$salesforceUser = [
'Email' => null,
];
app()->bind(QueryBuilder::class, function () {
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->expects($this->once())
->method('buildGetUsersQuery')
->with(null)
->willReturn('SELECT * FROM Users');
return $queryBuilder;
});
$queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->exactly(2))
->method('query')
->willReturn($queryIterator);
return $handler;
});
$team = $this->createMock(Team::class);
$user = $this->createMock(User::class);
$this->mockTeamRepository($team, $salesforceUser, $user, false);
$config = $this->createMock(Configuration::class);
$serviceMock = $this->getServiceMock();
$serviceMock->team = $team;
$serviceMock->config = $config;
$profile = $serviceMock->syncProfiles(null);
$this->assertNull($profile);
}
public function testSyncProfilesUserToSearchMatchesCurrentUser(): void
{
$userToSearch = $this->createMock(User::class);
$userToSearch->expects($this->once())
->method('getId')
->willReturn(123);
$team = $this->createMock(Team::class);
$config = $this->createMock(Configuration::class);
$config->expects($this->once())
->method('getId')
->willReturn(1);
$salesforceUser = [
'Email' => '[EMAIL]',
'UserPreferencesLightningExperiencePreferred' => true,
'CallCenterId' => '123',
'Id' => '456',
'ProfileId' => '789',
];
app()->bind(QueryBuilder::class, function () use ($userToSearch) {
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->expects($this->once())
->method('buildGetUsersQuery')
->with($userToSearch)
->willReturn('SELECT * FROM Users');
return $queryBuilder;
});
$queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('query')
->with('SELECT * FROM Users')
->willReturn($queryIterator);
return $handler;
});
$user = $this->createMock(User::class);
$user->expects($this->exactly(2))
->method('getId')
->willReturn(123);
$this->mockTeamRepository($team, $salesforceUser, $user);
$profileRepository = $this->createMock(ProfileRepository::class);
$profileRepository->expects($this->once())
->method('updateOrCreateProfile')
->with(
$user,
[
'crm_configuration_id' => 1,
'crm_provider_id' => '456',
],
[
'user_id' => 123,
'edition' => Profile::EDITION_LIGHTNING,
'has_external_cti' => true,
'crm_profile_id' => '789',
]
)
->willReturn(new Profile());
$this->app->instance(ProfileRepository::class, $profileRepository);
$serviceMock = $this->getServiceMock();
$serviceMock->team = $team;
$serviceMock->config = $config;
$profile = $serviceMock->syncProfiles($userToSearch);
$this->assertInstanceOf(Profile::class, $profile);
}
public function testSyncProfilesWithCustomValidation(): void
{
$userToSearch = null;
$team = $this->createMock(Team::class);
$config = $this->createMockedConfiguration();
$config->expects($this->atLeastOnce()) // Changed from once() to atLeastOnce()
->method('getId')
->willReturn(1);
$salesforceUser = [
'Email' => '[EMAIL]',
'UserPreferencesLightningExperiencePreferred' => true,
'CallCenterId' => '123',
'Id' => '456',
'ProfileId' => '789',
'CustomField' => 'CustomValue',
];
$customRules = [
['field' => 'CustomField', 'value' => 'CustomValue'],
];
$this->mockQueryBuilderAndHandler($userToSearch, [$salesforceUser]);
$user = $this->createMock(User::class);
$user->expects($this->once())
->method('getId')
->willReturn(123);
$this->mockTeamRepository($team, $salesforceUser, $user, true, $customRules);
$this->mockProfileRepository($user);
$serviceMock = $this->getServiceMock();
$serviceMock->team = $team;
$serviceMock->config = $config;
$profile = $serviceMock->syncProfiles($userToSearch);
$this->assertNull($profile);
}
public function testSyncProfilesWithCustomValidationFailing(): void
{
$userToSearch = null;
$team = $this->createMock(Team::class);
$config = $this->createMockedConfiguration();
$salesforceUser = [
'Email' => '[EMAIL]',
'UserPreferencesLightningExperiencePreferred' => true,
'CallCenterId' => '123',
'Id' => '456',
'ProfileId' => '789',
'CustomField' => 'WrongValue',
];
$customRules = [
['field' => 'CustomField', 'value' => 'CustomValue'],
];
$this->mockQueryBuilderAndHandler($userToSearch, [$salesforceUser]);
$teamRepository = $this->getMockForAbstractClass(TeamRepository::class, [], '', false, true, true, ['findActiveTeamMemberByEmail', 'getTeamSetting']);
$teamSettings = $this->createMock(TeamSettings::class);
$teamSettings->method('getValueType')
->willReturn('array');
$teamSettings->method('getValue')
->willReturn(json_encode($customRules));
$teamRepository->expects($this->once())
->method('getTeamSetting')
->with($team, 'custom_profile_validation')
->willReturn($teamSettings);
app()->bind(TeamRepository::class, function () use ($teamRepository) {
return $teamRepository;
});
$profileRepository = $this->createMock(ProfileRepository::class);
$profileRepository->expects($this->never())
->method('updateOrCreateProfile');
$this->app->instance(ProfileRepository::class, $profileRepository);
$serviceMock = $this->getServiceMock();
$serviceMock->team = $team;
$serviceMock->config = $config;
$result = $serviceMock->syncProfiles($userToSearch);
$this->assertNull($result);
}
public function testGetContactRolesFromCrm(): void
{
$contactRoles = [
[
'Id' => '1',
'ContactId' => 'Contact1',
'OpportunityId' => 'Opportunity1',
'Opportunity' => ['OwnerId' => 'Owner1'],
'IsPrimary' => true,
'Role' => 'Decision Maker',
],
];
$expectedResponse = [
[
'id' => '1',
'contactId' => 'Contact1',
'opportunityId' => 'Opportunity1',
'ownerId' => 'Owner1',
'isPrimary' => true,
'role' => 'Decision Maker',
],
];
$this->bindQueryIterator($contactRoles);
$serviceMock = $this->getServiceMock();
$data = $serviceMock->getContactRolesFromCrm(now()->subDay());
$this->assertEquals($expectedResponse, $data);
}
public function testGetContactRolesFromCrmNoResult(): void
{
$this->bindQueryIterator([]);
$serviceMock = $this->getServiceMock();
$data = $serviceMock->getContactRolesFromCrm(now()->subDay());
$this->assertEquals([], $data);
}
public function testSyncContactRoles(): void
{
$contactRoles = [
[
'id' => '1',
'contactId' => 'Contact1',
'opportunityId' => 'Opportunity1',
'ownerId' => 'Owner1',
'isPrimary' => true,
'role' => 'Decision Maker',
],
];
app()->bind(ContactRoleRepository::class, function () {
$contactRoleRepository = $this->createMock(ContactRoleRepository::class);
$contactRoleRepository->expects($this->once())
->method('saveContactRoles');
return $contactRoleRepository;
});
$serviceMock = $this->getServiceMock([
'getContactRolesFromCrm',
'syncRemotelyDeletedContactRoles',
'syncContact',
'syncOpportunity',
]);
$config = $this->createMock(Configuration::class);
$hasMany = $this->createMock(HasManyExtended::class);
$hasMany->expects($this->exactly(2))
->method('where')
->willReturn($hasMany);
$hasMany->expects($this->exactly(2))
->method('first')
->willReturn(
$this->createMock(Contact::class),
$this->createMock(Opportunity::class)
);
$config->expects($this->once())
->method('contacts')
->willReturn($hasMany);
$config->expects($this->once())
->method('opportunities')
->willReturn($hasMany);
$serviceMock->config = $config;
$serviceMock->expects($this->once())
->method('getContactRolesFromCrm')
->willReturn($contactRoles);
$serviceMock->expects($this->once())
->method('syncRemotelyDeletedContactRoles');
$data = $serviceMock->syncContactRoles(now()->subDay());
$this->assertEquals(1, $data);
}
public function testSyncRemotelyDeletedContactRoles(): void
{
$contactRoles = [
[
'id' => '1',
'crm_provider_id' => '1',
],
];
app()->bind(QueryHandler::class, function () use ($contactRoles) {
$queryResults = new QueryResults($contactRoles, 1, true, null);
$handler = $this->createMock(QueryHandler::class);
$handler->method('queryDeleted')
->willReturn($queryResults);
return $handler;
});
app()->bind(ContactRoleRepository::class, function () {
$contactRoleRepository = $this->createMock(ContactRoleRepository::class);
$contactRoleRepository->expects($this->once())
->method('deleteContactRoles');
return $contactRoleRepository;
});
$serviceMock = $this->getServiceMock();
$serviceMock->team = $this->createMock(Team::class);
$data = $this->invokePrivateMethod('syncRemotelyDeletedContactRoles', $serviceMock, []);
$this->assertTrue($data);
}
private function bindQueryIterator(array $queryResult): void
{
/** @var Client $client */
$client = $this->createMock(Client::class);
$queryIterator = new QueryIterator(
$client,
new QueryResults($queryResult, 1, true, null)
);
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('query')
->willReturn($queryIterator);
return $handler;
});
}
public static function getOpportunitySortOrderDataProvider(): array
{
return [
'all open recently updated' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED, ['LastModifiedDate DESC', true]],
'all open recently created' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED, ['CreatedDate DESC', true]],
'all open oldest created' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED, ['CreatedDate ASC', true]],
'all recently updated' => [Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED, ['LastModifiedDate DESC', false]],
'default' => ['unknown', ['LastModifiedDate DESC', true]],
];
}
private function createMockedConfiguration(): Configuration
{
$config = $this->createMock(Configuration::class);
$profilesRelation = $this->getMockBuilder(\Illuminate\Database\Eloquent\Relations\HasMany::class)
->disableOriginalConstructor()
->onlyMethods(['get'])
->addMethods(['where', 'first'])
->getMock();
$profilesRelation->method('where')->willReturnSelf();
$profilesRelation->method('get')->willReturn(collect([]));
$profilesRelation->method('first')->willReturn(null);
$config->method('profiles')->willReturn($profilesRelation);
return $config;
}
private function getServiceMock(array $onlyMethods = []): MockObject&Service
{
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$this->createMock(Client::class),
$this->createMock(PayloadBuilder::class),
$this->createMock(Dispatcher::class),
$this->createMock(CountriesMap::class),
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods($onlyMethods)
->getMock();
$serviceMock->profile = $this->createMock(Profile::class);
return $serviceMock;
}
private function mockQueryBuilderAndHandler($userToSearch, $salesforceUsers): void
{
app()->bind(QueryBuilder::class, function () use ($userToSearch) {
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->expects($this->once())
->method('buildGetUsersQuery')
->with($userToSearch)
->willReturn('SELECT * FROM Users');
return $queryBuilder;
});
$queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults($salesforceUsers, count($salesforceUsers), true, null));
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('query')
->willReturn($queryIterator);
return $handler;
});
}
private function mockTeamRepository(
Team $team,
array $salesforceUser,
?User $user = null,
bool $userSearch = true,
array $customRules = []
): void {
$teamRepository = $this->getMockForAbstractClass(TeamRepository::class, [], '', false, true, true, ['findActiveTeamMemberByEmail', 'getTeamSetting']);
if ($userSearch) {
$teamRepository->expects($this->once())
->method('findActiveTeamMemberByEmail')
->with($team, $salesforceUser['Email'])
->willReturn($user);
}
$teamSettings = $this->createMock(TeamSettings::class);
$teamSettings->method('getValueType')
->willReturn('array');
$teamSettings->method('getValue')
->willReturn(json_encode($customRules));
$teamRepository->expects($this->once())
->method('getTeamSetting')
->with($team, 'custom_profile_validation')
->willReturn($teamSettings);
app()->bind(TeamRepository::class, function () use ($teamRepository) {
return $teamRepository;
});
}
private function mockProfileRepository(User $user): void
{
$profileRepository = $this->createMock(ProfileRepository::class);
$profileRepository->expects($this->once())
->method('updateOrCreateProfile')
->with(
$user,
[
'crm_configuration_id' => 1,
'crm_provider_id' => '456',
],
[
'user_id' => 123,
'edition' => Profile::EDITION_LIGHTNING,
'has_external_cti' => true,
'crm_profile_id' => '789',
]
)
->willReturn(new Profile());
$this->app->instance(ProfileRepository::class, $profileRepository);
}
public function testBuildEnhancedNoteDecodesHtmlEntities(): void
{
$service = $this->getServiceMock(['createRecord']);
$profile = new Profile();
$profile->setAttribute('crm_provider_id', 'owner-123');
$service->profile = $profile;
$service->expects($this->exactly(2))
->method('createRecord')
->willReturnOnConsecutiveCalls('note-id-123', 'link-id-456');
$bodyWithEntities = 'Welch's current challenges and Facebook's Club';
$result = $this->invokePrivateMethod('buildEnhancedNote', $service, [
'Test Title',
$bodyWithEntities,
'object-id-789',
]);
$this->assertEquals('note-id-123', $result);
}
public function testBuildEnhancedNoteSanitizesWithoutQuotes(): void
{
$service = $this->getServiceMock(['createRecord']);
$profile = new Profile();
$profile->setAttribute('crm_provider_id', 'owner-456');
$service->profile = $profile;
$service->expects($this->exactly(2))
->method('createRecord')
->willReturnCallback(function ($type, $data) {
if ($type === 'ContentNote') {
$decoded = base64_decode($data['Content']);
$this->assertStringContainsString("Welch's", $decoded);
$this->assertStringNotContainsString(''', $decoded);
$this->assertStringNotContainsString('&#039;', $decoded);
$this->assertStringContainsString('<script>', $decoded);
return 'note-id-456';
}
return 'link-id-789';
});
$bodyWithMixedContent = "Welch's and <script>alert('xss')</script>";
$result = $this->invokePrivateMethod('buildEnhancedNote', $service, [
'Test Title',
$bodyWithMixedContent,
'object-id-123',
]);
$this->assertEquals('note-id-456', $result);
}
public function testBuildEnhancedNoteConvertsLineBreaks(): void
{
$service = $this->getServiceMock(['createRecord']);
$profile = new Profile();
$profile->setAttribute('crm_provider_id', 'owner-789');
$service->profile = $profile;
$service->expects($this->exactly(2))
->method('createRecord')
->willReturnCallback(function ($type, $data) {
if ($type === 'ContentNote') {
$decoded = base64_decode($data['Content']);
$this->assertStringContainsString('<br>', $decoded);
$this->assertStringNotContainsString('<br />', $decoded);
return 'note-id-789';
}
return 'link-id-012';
});
$bodyWithLineBreaks = "Line 1\nLine 2\nLine 3";
$result = $this->invokePrivateMethod('buildEnhancedNote', $service, [
'Test Title',
$bodyWithLineBreaks,
'object-id-456',
]);
$this->assertEquals('note-id-789', $result);
}
public function testBuildEnhancedNoteHandlesComplexScenario(): void
{
$service = $this->getServiceMock(['createRecord']);
$profile = new Profile();
$profile->setAttribute('crm_provider_id', 'owner-complex');
$service->profile = $profile;
$service->expects($this->exactly(2))
->method('createRecord')
->willReturnCallback(function ($type, $data) {
if ($type === 'ContentNote') {
$decoded = base64_decode($data['Content']);
$this->assertStringContainsString("Welch's", $decoded);
$this->assertStringContainsString("Facebook's Club", $decoded);
$this->assertStringContainsString("Arctics'", $decoded);
$this->assertStringNotContainsString(''', $decoded);
$this->assertStringNotContainsString('&#039;', $decoded);
$this->assertStringContainsString('<br>', $decoded);
$this->assertStringContainsString('<', $decoded);
$this->assertStringContainsString('>', $decoded);
return 'note-complex';
}
return 'link-complex';
});
$complexBody = "Summary:\n---------\nThe call focused on understanding Welch's current challenges and exploring how Arctics' Revenue Growth Management solutions could support their strategic goals.\n\n• John SMith discussed his role as a category advisor for Google and Facebook's Club, emphasizing the importance of market research and advising on product assortment.\n• Madona introduced Arctics' Virtual Shoppers AI, which simulates consumer <behavior> to optimize pricing and promotional strategies.";
$result = $this->invokePrivateMethod('buildEnhancedNote', $service, [
'Jiminny Transcription Summary',
$complexBody,
'task-id-001',
]);
$this->assertEquals('note-complex', $result);
}
public function testSyncRemotelyDeletedObjectsWithErrorHandlingSuccess(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$prospectPhotoPathService,
])
->onlyMethods(['syncRemotelyDeletedObjects'])
->getMock();
// Mock team
$team = $this->createMock(Team::class);
$team->method('getUuid')->willReturn('team-uuid-123');
$serviceMock->team = $team;
// Expect syncRemotelyDeletedObjects to be called once and succeed
$serviceMock->expects($this->once())
->method('syncRemotelyDeletedObjects')
->with(\Jiminny\Enums\CrmObject::ACCOUNT);
// Call the protected method using reflection
$reflection = new \ReflectionClass($serviceMock);
$method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');
$method->setAccessible(true);
// Should not throw any exceptions
$method->invoke($serviceMock, \Jiminny\Enums\CrmObject::ACCOUNT);
$this->assertTrue(true); // Test completed successfully
}
public function testSyncRemotelyDeletedObjectsWithErrorHandlingFailure(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$prospectPhotoPathService,
])
->onlyMethods(['syncRemotelyDeletedObjects'])
->getMock();
// Mock team
$team = $this->createMock(Team::class);
$team->method('getUuid')->willReturn('team-uuid-456');
$serviceMock->team = $team;
// Mock logger to verify warning is logged
$logger = $this->createMock(\Psr\Log\LoggerInterface::class);
// Use reflection to set the protected logger property
$reflection = new \ReflectionClass($serviceMock);
$loggerProperty = $reflection->getProperty('logger');
$loggerProperty->setAccessible(true);
$loggerProperty->setValue($serviceMock, $logger);
$exception = new \Exception('Sync failed due to API error');
// Expect syncRemotelyDeletedObjects to throw an exception
$serviceMock->expects($this->once())
->method('syncRemotelyDeletedObjects')
->with(\Jiminny\Enums\CrmObject::CONTACT)
->willThrowException($exception);
// Expect warning to be logged with correct message and parameters
$logger->expects($this->once())
->method('warning')
->with(
'[Salesforce] Remotely deleted objects sync failed',
[
'objectType' => 'contact',
'teamId' => 'team-uuid-456',
'reason' => 'Sync failed due to API error',
]
);
// Call the protected method using reflection
$reflection = new \ReflectionClass($serviceMock);
$method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');
$method->setAccessible(true);
// Should not re-throw the exception, just log it
$method->invoke($serviceMock, \Jiminny\Enums\CrmObject::CONTACT);
$this->assertTrue(true); // Test completed successfully
}
public function testSyncRemotelyDeletedObjectsWithErrorHandlingWithLogParams(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$prospectPhotoPathService,
])
->onlyMethods(['syncRemotelyDeletedObjects'])
->getMock();
// Mock team
$team = $this->createMock(Team::class);
$team->method('getUuid')->willReturn('team-uuid-789');
$serviceMock->team = $team;
// Mock logger to verify warning is logged
$logger = $this->createMock(\Psr\Log\LoggerInterface::class);
// Use reflection to set the protected logger property
$loggerReflection = new \ReflectionClass($serviceMock);
$loggerProperty = $loggerReflection->getProperty('logger');
$loggerProperty->setAccessible(true);
$loggerProperty->setValue($serviceMock, $logger);
$exception = new \Exception('Network timeout');
// Expect syncRemotelyDeletedObjects to throw an exception
$serviceMock->expects($this->once())
->method('syncRemotelyDeletedObjects')
->with(\Jiminny\Enums\CrmObject::OPPORTUNITY)
->willThrowException($exception);
// Additional log parameters
$logParams = [
'syncType' => 'full',
'batchSize' => 100,
];
// Expect warning to be logged with merged parameters
$logger->expects($this->once())
->method('warning')
->with(
'[Salesforce] Remotely deleted objects sync failed',
[
'objectType' => 'opportunity',
'teamId' => 'team-uuid-789',
'reason' => 'Network timeout',
'syncType' => 'full',
'batchSize' => 100,
]
);
// Call the protected method using reflection
$reflection = new \ReflectionClass($serviceMock);
$method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');
...
|
[{"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-20963-fix-import-on-deleted-entity, menu","depth":5,"on_screen":true,"help_text":"Git Branch: JY-20963-fix-import-on-deleted-entity","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"ServiceTest","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Rerun 'PHPUnit: ServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'ServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Stop 'ServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Built-in Preview","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":"Chrome","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":"Firefox","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":"Safari","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":"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":"4","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"32","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"168","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"28","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Services\\Crm\\Salesforce;\n\nuse Illuminate\\Database\\Eloquent\\Relations\\HasMany;\nuse Illuminate\\Events\\Dispatcher;\nuse Illuminate\\Support\\Facades\\Cache;\nuse Jiminny\\Component\\Country\\CountriesMap;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Enums\\CrmObject;\nuse Jiminny\\Exceptions\\InvalidArgumentException;\nuse Jiminny\\Exceptions\\NoResultsException;\nuse Jiminny\\Integrations\\PlaybookResolver;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Crm\\Configuration;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\TeamSettings;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\ContactRoleRepository;\nuse Jiminny\\Repositories\\Crm\\FieldDataRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\Salesforce\\Client;\nuse Jiminny\\Services\\Crm\\Salesforce\\PayloadBuilder;\nuse Jiminny\\Services\\Crm\\Salesforce\\QueryBuilder;\nuse Jiminny\\Services\\Crm\\Salesforce\\QueryHandler;\nuse Jiminny\\Services\\Crm\\Salesforce\\QueryIterator;\nuse Jiminny\\Services\\Crm\\Salesforce\\QueryResults;\nuse Jiminny\\Services\\Crm\\Salesforce\\Service;\nuse PHPUnit\\Framework\\MockObject\\MockObject;\nuse Tests\\TestCase;\nuse Tests\\Unit\\Traits\\TestPrivateMethod;\n\nclass ServiceTest extends TestCase\n{\n use TestPrivateMethod;\n\n public function testFetchAndAssociateRelatedActivity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $payloadBuilder->method('addCustomLogicFieldsPayload')\n ->willReturnCallback(function ($activity, $payload) {\n return $payload;\n });\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods(['fetchRelatedActivity', 'getPlaybook', 'getPlaybookCategory', 'updateRecord'])\n ->getMock();\n\n $serviceMock->expects($this->once())\n ->method('fetchRelatedActivity')\n ->willReturn([\n 'Id' => 'testId',\n 'Type' => null,\n 'OwnerId' => 'testerUser',\n 'Description' => 'Test description',\n ]);\n\n $user = $this->createMock(User::class);\n $team = $this->createMock(Team::class);\n $user->method('getAttribute')->with('team')->willReturn($team);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityField')->willReturn(null);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_EVENT);\n\n $serviceMock->expects($this->once())\n ->method('getPlaybook')\n ->with($user)\n ->willReturn($playbook);\n\n $serviceMock->expects($this->never())\n ->method('getPlaybookCategory');\n\n $serviceMock->expects($this->never())\n ->method('updateRecord');\n\n $fieldDataRepository = $this->createMock(FieldDataRepository::class);\n $fieldDataRepository->method('getActivityFieldData')->willReturn(collect([]));\n app()->instance(FieldDataRepository::class, $fieldDataRepository);\n\n $config = $this->createMock(Configuration::class);\n $profilesRelation = $this->getMockBuilder(\\Illuminate\\Database\\Eloquent\\Relations\\HasMany::class)\n ->disableOriginalConstructor()\n ->onlyMethods(['get'])\n ->addMethods(['where'])\n ->getMock();\n $profilesRelation->method('where')->willReturnSelf();\n $profilesRelation->method('get')->willReturn(collect([]));\n $config->method('profiles')->willReturn($profilesRelation);\n\n $serviceMock->config = $config;\n $serviceMock->profile = null;\n\n $actualStartTime = \\Carbon\\Carbon::now();\n\n $activity = $this->getMockBuilder(Activity::class)\n ->disableOriginalConstructor()\n ->onlyMethods(['update', 'hasProspect'])\n ->getMock();\n\n $activity->method('update')->willReturn(true);\n $activity->method('hasProspect')->willReturn(true);\n\n $activity->type = Activity::TYPE_CONFERENCE;\n $activity->provider = Activity::PROVIDER_TWILIO;\n $activity->lead_id = 1;\n $activity->user_id = 0;\n $activity->id_string = 'test-activity-id';\n $activity->user = $user;\n\n $activity->actual_start_time = $actualStartTime;\n $activity->uuid = 'c53d8320-f556-4cee-a2f8-5f232f454ca4';\n\n app()->bind(PlaybookResolver::class, function () use ($user) {\n $playbook = $this->createMock(Playbook::class);\n $playbookResolver = $this->createMock(PlaybookResolver::class);\n $playbookResolver->expects($this->once())\n ->method('resolvePlaybookByUser')\n ->with($user)\n ->willReturn($playbook);\n\n return $playbookResolver;\n });\n\n $data = $serviceMock->fetchAndAssociateRelatedActivity($activity);\n\n $this->assertInstanceOf(Activity::class, $data);\n $this->assertEquals(Activity::TYPE_CONFERENCE, $data->getType());\n $this->assertEquals($actualStartTime->getTimestamp(), $data->getActualStartTime()->getTimestamp());\n }\n\n public function testFetchAndAssociateRelatedActivitySkipsForTaskBasedPlaybook(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods(['fetchRelatedActivity', 'getPlaybook'])\n ->getMock();\n\n $user = $this->createMock(User::class);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n $playbook->method('getId')->willReturn(123);\n\n $serviceMock->expects($this->once())\n ->method('getPlaybook')\n ->with($user)\n ->willReturn($playbook);\n\n $serviceMock->expects($this->never())\n ->method('fetchRelatedActivity');\n\n $activity = $this->getMockBuilder(Activity::class)\n ->disableOriginalConstructor()\n ->onlyMethods(['hasProspect', 'getUuid'])\n ->getMock();\n\n $activity->method('hasProspect')->willReturn(true);\n $activity->method('getUuid')->willReturn('c53d8320-f556-4cee-a2f8-5f232f454ca4');\n $activity->type = Activity::TYPE_CONFERENCE;\n $activity->actual_start_time = \\Carbon\\Carbon::now();\n $activity->user = $user;\n\n $result = $serviceMock->fetchAndAssociateRelatedActivity($activity);\n\n $this->assertNull($result);\n }\n\n public function testFetchAndAssociateRelatedActivityReturnsNullForNonConference(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $serviceMock = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)\n );\n\n $activity = $this->getMockBuilder(Activity::class)\n ->disableOriginalConstructor()\n ->getMock();\n\n $activity->type = Activity::TYPE_SOFTPHONE;\n\n $result = $serviceMock->fetchAndAssociateRelatedActivity($activity);\n\n $this->assertNull($result);\n }\n\n public function testFetchAndAssociateRelatedActivityReturnsNullWhenNoStartTime(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $serviceMock = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)\n );\n\n\n $activity = $this->getMockBuilder(Activity::class)\n ->disableOriginalConstructor()\n ->getMock();\n\n $activity->type = Activity::TYPE_CONFERENCE;\n $activity->actual_start_time = null;\n $activity->scheduled_start_time = null;\n\n $result = $serviceMock->fetchAndAssociateRelatedActivity($activity);\n\n $this->assertNull($result);\n }\n\n public function testFetchAndAssociateRelatedActivityReturnsNullWhenNoProspect(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods(['getPlaybook'])\n ->getMock();\n\n $serviceMock->expects($this->never())\n ->method('getPlaybook');\n\n $activity = $this->getMockBuilder(Activity::class)\n ->disableOriginalConstructor()\n ->onlyMethods(['hasProspect', 'getUuid'])\n ->getMock();\n\n $activity->method('hasProspect')->willReturn(false);\n $activity->method('getUuid')->willReturn('c53d8320-f556-4cee-a2f8-5f232f454ca4');\n $activity->type = Activity::TYPE_CONFERENCE;\n $activity->actual_start_time = \\Carbon\\Carbon::now();\n\n $result = $serviceMock->fetchAndAssociateRelatedActivity($activity);\n\n $this->assertNull($result);\n }\n\n public function testMatchExactlyByEmail(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods([])\n ->getMock();\n\n $profile = new Profile();\n $profile->setAttribute('id', bin2hex(random_bytes(8)));\n $serviceMock->profile = $profile;\n\n $team = $this->createMock(Team::class);\n $serviceMock->team = $team;\n\n $data = $serviceMock->matchExactlyByEmail(bin2hex(random_bytes(8)) . 'test_email@testserver.com');\n\n $this->assertEquals(null, $data);\n }\n\n public function testMatchDomainFromEmail(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $queryIterator = $this->createMock(QueryIterator::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $config = $this->createMock(Configuration::class);\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('search')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods(['convertCrmData'])\n ->getMock();\n\n $profile = new Profile();\n $profile->account_fields = 'Field1, Field2, Field3';\n $serviceMock->profile = $profile;\n\n $serviceMock->expects($this->once())\n ->method('convertCrmData')\n ->willReturn(['test']);\n\n $this->app->bind(QueryBuilder::class, function () {\n $queryBuilder = $this->createMock(QueryBuilder::class);\n $queryBuilder->expects($this->once())\n ->method('buildMatchByDomainQuery')\n ->with('test_email@testserver.com')\n ->willReturn('FIND {test_email@testserver.com} IN ALL FIELDS RETURNING Account(Id)');\n\n return $queryBuilder;\n });\n\n $team = $this->createMock(Team::class);\n\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n\n $data = $serviceMock->matchByDomain('test_email@testserver.com');\n\n $this->assertEquals(['test'], $data);\n }\n\n public function testBuildTaskSearchFields(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)\n );\n\n $fields = $service->buildTaskSearchFields();\n\n $expectedFields = ['Id', 'WhoId', 'WhatId', 'AccountId'];\n\n $this->assertEquals($expectedFields, $fields);\n }\n\n public function testMapCrmObjects(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class),\n );\n\n $sampleTask = [\n 'WhoId' => '003sampleWhoId',\n 'AccountId' => 'sampleAccountId',\n 'WhatId' => 'sampleWhatId',\n ];\n\n $activityData = $service->mapCrmObjects($sampleTask);\n\n $expectedActivityData = [\n 'contact' => '003sampleWhoId',\n 'account' => 'sampleAccountId',\n 'opportunity' => 'sampleWhatId',\n ];\n\n $this->assertEquals($expectedActivityData, $activityData);\n }\n\n public function testGetInstalledAppVersion(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n $queryIterator = $this->createMock(QueryIterator::class);\n $queryIterator->expects($this->any())\n ->method('current')->willReturn([\n 'SubscriberPackageVersion' => [\n 'MajorVersion' => '1',\n 'MinorVersion' => '0',\n 'PatchVersion' => '1',\n 'BuildNumber' => '0',\n ],\n ]);\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('metadata')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods(array_diff(get_class_methods(Service::class), ['getInstalledAppVersion']))\n ->getMock();\n\n $version = $serviceMock->getInstalledAppVersion();\n\n $this->assertEquals('1010', $version);\n }\n\n public function testSyncProfiles(): void\n {\n $userToSearch = null;\n\n $team = $this->createMock(Team::class);\n $config = $this->createMockedConfiguration();\n $config->expects($this->once())\n ->method('getId')\n ->willReturn(1);\n\n $salesforceUser = [\n 'Email' => 'test@example.com',\n 'UserPreferencesLightningExperiencePreferred' => true,\n 'CallCenterId' => '123',\n 'Id' => '456',\n 'ProfileId' => '789',\n ];\n\n app()->bind(QueryBuilder::class, function () use ($userToSearch) {\n $queryBuilder = $this->createMock(QueryBuilder::class);\n $queryBuilder->expects($this->once())\n ->method('buildGetUsersQuery')\n ->with($userToSearch)\n ->willReturn('SELECT * FROM Users');\n\n return $queryBuilder;\n });\n\n $queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('query')\n ->with('SELECT * FROM Users')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n\n $user = $this->createMock(User::class);\n $user->expects($this->once())\n ->method('getId')\n ->willReturn(123);\n\n $this->mockTeamRepository($team, $salesforceUser, $user);\n\n $profileRepository = $this->createMock(ProfileRepository::class);\n $profileRepository->expects($this->once())\n ->method('updateOrCreateProfile')\n ->with(\n $user,\n [\n 'crm_configuration_id' => 1,\n 'crm_provider_id' => '456',\n ],\n [\n 'user_id' => 123,\n 'edition' => Profile::EDITION_LIGHTNING,\n 'has_external_cti' => true,\n 'crm_profile_id' => '789',\n ]\n )\n ->willReturn(new Profile());\n\n $this->app->instance(ProfileRepository::class, $profileRepository);\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n $result = $serviceMock->syncProfiles($userToSearch);\n\n $this->assertNull($result);\n }\n\n public function testSyncProfilesEmailIsNull(): void\n {\n $userToSearch = $this->createMock(User::class);\n\n $salesforceUser = [\n 'Email' => null,\n ];\n\n app()->bind(QueryBuilder::class, function () {\n $queryBuilder = $this->createMock(QueryBuilder::class);\n $queryBuilder->expects($this->once())\n ->method('buildGetUsersQuery')\n ->with(null)\n ->willReturn('SELECT * FROM Users');\n\n return $queryBuilder;\n });\n\n $queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->exactly(2))\n ->method('query')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n\n $team = $this->createMock(Team::class);\n $user = $this->createMock(User::class);\n $this->mockTeamRepository($team, $salesforceUser, $user, false);\n\n $config = $this->createMock(Configuration::class);\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n\n $profile = $serviceMock->syncProfiles(null);\n\n $this->assertNull($profile);\n }\n\n public function testSyncProfilesUserToSearchMatchesCurrentUser(): void\n {\n $userToSearch = $this->createMock(User::class);\n $userToSearch->expects($this->once())\n ->method('getId')\n ->willReturn(123);\n\n $team = $this->createMock(Team::class);\n $config = $this->createMock(Configuration::class);\n $config->expects($this->once())\n ->method('getId')\n ->willReturn(1);\n\n $salesforceUser = [\n 'Email' => 'test@example.com',\n 'UserPreferencesLightningExperiencePreferred' => true,\n 'CallCenterId' => '123',\n 'Id' => '456',\n 'ProfileId' => '789',\n ];\n\n app()->bind(QueryBuilder::class, function () use ($userToSearch) {\n $queryBuilder = $this->createMock(QueryBuilder::class);\n $queryBuilder->expects($this->once())\n ->method('buildGetUsersQuery')\n ->with($userToSearch)\n ->willReturn('SELECT * FROM Users');\n\n return $queryBuilder;\n });\n\n $queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('query')\n ->with('SELECT * FROM Users')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n\n $user = $this->createMock(User::class);\n $user->expects($this->exactly(2))\n ->method('getId')\n ->willReturn(123);\n\n $this->mockTeamRepository($team, $salesforceUser, $user);\n\n $profileRepository = $this->createMock(ProfileRepository::class);\n $profileRepository->expects($this->once())\n ->method('updateOrCreateProfile')\n ->with(\n $user,\n [\n 'crm_configuration_id' => 1,\n 'crm_provider_id' => '456',\n ],\n [\n 'user_id' => 123,\n 'edition' => Profile::EDITION_LIGHTNING,\n 'has_external_cti' => true,\n 'crm_profile_id' => '789',\n ]\n )\n ->willReturn(new Profile());\n\n $this->app->instance(ProfileRepository::class, $profileRepository);\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n $profile = $serviceMock->syncProfiles($userToSearch);\n\n $this->assertInstanceOf(Profile::class, $profile);\n }\n\n public function testSyncProfilesWithCustomValidation(): void\n {\n $userToSearch = null;\n\n $team = $this->createMock(Team::class);\n $config = $this->createMockedConfiguration();\n $config->expects($this->atLeastOnce()) // Changed from once() to atLeastOnce()\n ->method('getId')\n ->willReturn(1);\n\n $salesforceUser = [\n 'Email' => 'test@example.com',\n 'UserPreferencesLightningExperiencePreferred' => true,\n 'CallCenterId' => '123',\n 'Id' => '456',\n 'ProfileId' => '789',\n 'CustomField' => 'CustomValue',\n ];\n\n $customRules = [\n ['field' => 'CustomField', 'value' => 'CustomValue'],\n ];\n\n $this->mockQueryBuilderAndHandler($userToSearch, [$salesforceUser]);\n\n $user = $this->createMock(User::class);\n $user->expects($this->once())\n ->method('getId')\n ->willReturn(123);\n\n $this->mockTeamRepository($team, $salesforceUser, $user, true, $customRules);\n\n $this->mockProfileRepository($user);\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n $profile = $serviceMock->syncProfiles($userToSearch);\n\n $this->assertNull($profile);\n }\n\n public function testSyncProfilesWithCustomValidationFailing(): void\n {\n $userToSearch = null;\n\n $team = $this->createMock(Team::class);\n $config = $this->createMockedConfiguration();\n\n $salesforceUser = [\n 'Email' => 'test@example.com',\n 'UserPreferencesLightningExperiencePreferred' => true,\n 'CallCenterId' => '123',\n 'Id' => '456',\n 'ProfileId' => '789',\n 'CustomField' => 'WrongValue',\n ];\n\n $customRules = [\n ['field' => 'CustomField', 'value' => 'CustomValue'],\n ];\n\n $this->mockQueryBuilderAndHandler($userToSearch, [$salesforceUser]);\n\n $teamRepository = $this->getMockForAbstractClass(TeamRepository::class, [], '', false, true, true, ['findActiveTeamMemberByEmail', 'getTeamSetting']);\n\n $teamSettings = $this->createMock(TeamSettings::class);\n $teamSettings->method('getValueType')\n ->willReturn('array');\n\n $teamSettings->method('getValue')\n ->willReturn(json_encode($customRules));\n\n $teamRepository->expects($this->once())\n ->method('getTeamSetting')\n ->with($team, 'custom_profile_validation')\n ->willReturn($teamSettings);\n\n app()->bind(TeamRepository::class, function () use ($teamRepository) {\n return $teamRepository;\n });\n\n $profileRepository = $this->createMock(ProfileRepository::class);\n $profileRepository->expects($this->never())\n ->method('updateOrCreateProfile');\n\n $this->app->instance(ProfileRepository::class, $profileRepository);\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n $result = $serviceMock->syncProfiles($userToSearch);\n\n $this->assertNull($result);\n }\n\n public function testGetContactRolesFromCrm(): void\n {\n $contactRoles = [\n [\n 'Id' => '1',\n 'ContactId' => 'Contact1',\n 'OpportunityId' => 'Opportunity1',\n 'Opportunity' => ['OwnerId' => 'Owner1'],\n 'IsPrimary' => true,\n 'Role' => 'Decision Maker',\n ],\n ];\n\n $expectedResponse = [\n [\n 'id' => '1',\n 'contactId' => 'Contact1',\n 'opportunityId' => 'Opportunity1',\n 'ownerId' => 'Owner1',\n 'isPrimary' => true,\n 'role' => 'Decision Maker',\n ],\n ];\n\n $this->bindQueryIterator($contactRoles);\n\n $serviceMock = $this->getServiceMock();\n\n $data = $serviceMock->getContactRolesFromCrm(now()->subDay());\n\n $this->assertEquals($expectedResponse, $data);\n }\n\n public function testGetContactRolesFromCrmNoResult(): void\n {\n $this->bindQueryIterator([]);\n\n $serviceMock = $this->getServiceMock();\n\n $data = $serviceMock->getContactRolesFromCrm(now()->subDay());\n\n $this->assertEquals([], $data);\n }\n\n public function testSyncContactRoles(): void\n {\n $contactRoles = [\n [\n 'id' => '1',\n 'contactId' => 'Contact1',\n 'opportunityId' => 'Opportunity1',\n 'ownerId' => 'Owner1',\n 'isPrimary' => true,\n 'role' => 'Decision Maker',\n ],\n ];\n\n app()->bind(ContactRoleRepository::class, function () {\n $contactRoleRepository = $this->createMock(ContactRoleRepository::class);\n $contactRoleRepository->expects($this->once())\n ->method('saveContactRoles');\n\n return $contactRoleRepository;\n });\n\n $serviceMock = $this->getServiceMock([\n 'getContactRolesFromCrm',\n 'syncRemotelyDeletedContactRoles',\n 'syncContact',\n 'syncOpportunity',\n ]);\n\n $config = $this->createMock(Configuration::class);\n $hasMany = $this->createMock(HasManyExtended::class);\n $hasMany->expects($this->exactly(2))\n ->method('where')\n ->willReturn($hasMany);\n\n $hasMany->expects($this->exactly(2))\n ->method('first')\n ->willReturn(\n $this->createMock(Contact::class),\n $this->createMock(Opportunity::class)\n );\n\n $config->expects($this->once())\n ->method('contacts')\n ->willReturn($hasMany);\n $config->expects($this->once())\n ->method('opportunities')\n ->willReturn($hasMany);\n\n $serviceMock->config = $config;\n\n $serviceMock->expects($this->once())\n ->method('getContactRolesFromCrm')\n ->willReturn($contactRoles);\n\n $serviceMock->expects($this->once())\n ->method('syncRemotelyDeletedContactRoles');\n\n $data = $serviceMock->syncContactRoles(now()->subDay());\n\n $this->assertEquals(1, $data);\n }\n\n public function testSyncRemotelyDeletedContactRoles(): void\n {\n $contactRoles = [\n [\n 'id' => '1',\n 'crm_provider_id' => '1',\n ],\n ];\n\n app()->bind(QueryHandler::class, function () use ($contactRoles) {\n $queryResults = new QueryResults($contactRoles, 1, true, null);\n\n $handler = $this->createMock(QueryHandler::class);\n $handler->method('queryDeleted')\n ->willReturn($queryResults);\n\n return $handler;\n });\n\n app()->bind(ContactRoleRepository::class, function () {\n $contactRoleRepository = $this->createMock(ContactRoleRepository::class);\n $contactRoleRepository->expects($this->once())\n ->method('deleteContactRoles');\n\n return $contactRoleRepository;\n });\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $this->createMock(Team::class);\n\n $data = $this->invokePrivateMethod('syncRemotelyDeletedContactRoles', $serviceMock, []);\n\n $this->assertTrue($data);\n }\n\n private function bindQueryIterator(array $queryResult): void\n {\n /** @var Client $client */\n $client = $this->createMock(Client::class);\n $queryIterator = new QueryIterator(\n $client,\n new QueryResults($queryResult, 1, true, null)\n );\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('query')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n }\n\n public static function getOpportunitySortOrderDataProvider(): array\n {\n return [\n 'all open recently updated' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED, ['LastModifiedDate DESC', true]],\n 'all open recently created' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED, ['CreatedDate DESC', true]],\n 'all open oldest created' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED, ['CreatedDate ASC', true]],\n 'all recently updated' => [Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED, ['LastModifiedDate DESC', false]],\n 'default' => ['unknown', ['LastModifiedDate DESC', true]],\n ];\n }\n\n private function createMockedConfiguration(): Configuration\n {\n $config = $this->createMock(Configuration::class);\n $profilesRelation = $this->getMockBuilder(\\Illuminate\\Database\\Eloquent\\Relations\\HasMany::class)\n ->disableOriginalConstructor()\n ->onlyMethods(['get'])\n ->addMethods(['where', 'first'])\n ->getMock();\n $profilesRelation->method('where')->willReturnSelf();\n $profilesRelation->method('get')->willReturn(collect([]));\n $profilesRelation->method('first')->willReturn(null);\n $config->method('profiles')->willReturn($profilesRelation);\n\n return $config;\n }\n\n private function getServiceMock(array $onlyMethods = []): MockObject&Service\n {\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $this->createMock(Client::class),\n $this->createMock(PayloadBuilder::class),\n $this->createMock(Dispatcher::class),\n $this->createMock(CountriesMap::class),\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods($onlyMethods)\n ->getMock();\n\n $serviceMock->profile = $this->createMock(Profile::class);\n\n return $serviceMock;\n }\n\n private function mockQueryBuilderAndHandler($userToSearch, $salesforceUsers): void\n {\n app()->bind(QueryBuilder::class, function () use ($userToSearch) {\n $queryBuilder = $this->createMock(QueryBuilder::class);\n $queryBuilder->expects($this->once())\n ->method('buildGetUsersQuery')\n ->with($userToSearch)\n ->willReturn('SELECT * FROM Users');\n\n return $queryBuilder;\n });\n\n $queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults($salesforceUsers, count($salesforceUsers), true, null));\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('query')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n }\n\n private function mockTeamRepository(\n Team $team,\n array $salesforceUser,\n ?User $user = null,\n bool $userSearch = true,\n array $customRules = []\n ): void {\n $teamRepository = $this->getMockForAbstractClass(TeamRepository::class, [], '', false, true, true, ['findActiveTeamMemberByEmail', 'getTeamSetting']);\n\n if ($userSearch) {\n $teamRepository->expects($this->once())\n ->method('findActiveTeamMemberByEmail')\n ->with($team, $salesforceUser['Email'])\n ->willReturn($user);\n }\n\n $teamSettings = $this->createMock(TeamSettings::class);\n $teamSettings->method('getValueType')\n ->willReturn('array');\n\n $teamSettings->method('getValue')\n ->willReturn(json_encode($customRules));\n\n $teamRepository->expects($this->once())\n ->method('getTeamSetting')\n ->with($team, 'custom_profile_validation')\n ->willReturn($teamSettings);\n\n app()->bind(TeamRepository::class, function () use ($teamRepository) {\n return $teamRepository;\n });\n }\n\n private function mockProfileRepository(User $user): void\n {\n $profileRepository = $this->createMock(ProfileRepository::class);\n $profileRepository->expects($this->once())\n ->method('updateOrCreateProfile')\n ->with(\n $user,\n [\n 'crm_configuration_id' => 1,\n 'crm_provider_id' => '456',\n ],\n [\n 'user_id' => 123,\n 'edition' => Profile::EDITION_LIGHTNING,\n 'has_external_cti' => true,\n 'crm_profile_id' => '789',\n ]\n )\n ->willReturn(new Profile());\n\n $this->app->instance(ProfileRepository::class, $profileRepository);\n }\n\n public function testBuildEnhancedNoteDecodesHtmlEntities(): void\n {\n $service = $this->getServiceMock(['createRecord']);\n\n $profile = new Profile();\n $profile->setAttribute('crm_provider_id', 'owner-123');\n $service->profile = $profile;\n\n $service->expects($this->exactly(2))\n ->method('createRecord')\n ->willReturnOnConsecutiveCalls('note-id-123', 'link-id-456');\n\n $bodyWithEntities = 'Welch's current challenges and Facebook's Club';\n\n $result = $this->invokePrivateMethod('buildEnhancedNote', $service, [\n 'Test Title',\n $bodyWithEntities,\n 'object-id-789',\n ]);\n\n $this->assertEquals('note-id-123', $result);\n }\n\n public function testBuildEnhancedNoteSanitizesWithoutQuotes(): void\n {\n $service = $this->getServiceMock(['createRecord']);\n\n $profile = new Profile();\n $profile->setAttribute('crm_provider_id', 'owner-456');\n $service->profile = $profile;\n\n $service->expects($this->exactly(2))\n ->method('createRecord')\n ->willReturnCallback(function ($type, $data) {\n if ($type === 'ContentNote') {\n $decoded = base64_decode($data['Content']);\n $this->assertStringContainsString(\"Welch's\", $decoded);\n $this->assertStringNotContainsString(''', $decoded);\n $this->assertStringNotContainsString('&#039;', $decoded);\n $this->assertStringContainsString('<script>', $decoded);\n\n return 'note-id-456';\n }\n\n return 'link-id-789';\n });\n\n $bodyWithMixedContent = \"Welch's and <script>alert('xss')</script>\";\n\n $result = $this->invokePrivateMethod('buildEnhancedNote', $service, [\n 'Test Title',\n $bodyWithMixedContent,\n 'object-id-123',\n ]);\n\n $this->assertEquals('note-id-456', $result);\n }\n\n public function testBuildEnhancedNoteConvertsLineBreaks(): void\n {\n $service = $this->getServiceMock(['createRecord']);\n\n $profile = new Profile();\n $profile->setAttribute('crm_provider_id', 'owner-789');\n $service->profile = $profile;\n\n $service->expects($this->exactly(2))\n ->method('createRecord')\n ->willReturnCallback(function ($type, $data) {\n if ($type === 'ContentNote') {\n $decoded = base64_decode($data['Content']);\n $this->assertStringContainsString('<br>', $decoded);\n $this->assertStringNotContainsString('<br />', $decoded);\n\n return 'note-id-789';\n }\n\n return 'link-id-012';\n });\n\n $bodyWithLineBreaks = \"Line 1\\nLine 2\\nLine 3\";\n\n $result = $this->invokePrivateMethod('buildEnhancedNote', $service, [\n 'Test Title',\n $bodyWithLineBreaks,\n 'object-id-456',\n ]);\n\n $this->assertEquals('note-id-789', $result);\n }\n\n public function testBuildEnhancedNoteHandlesComplexScenario(): void\n {\n $service = $this->getServiceMock(['createRecord']);\n\n $profile = new Profile();\n $profile->setAttribute('crm_provider_id', 'owner-complex');\n $service->profile = $profile;\n\n $service->expects($this->exactly(2))\n ->method('createRecord')\n ->willReturnCallback(function ($type, $data) {\n if ($type === 'ContentNote') {\n $decoded = base64_decode($data['Content']);\n\n $this->assertStringContainsString(\"Welch's\", $decoded);\n $this->assertStringContainsString(\"Facebook's Club\", $decoded);\n $this->assertStringContainsString(\"Arctics'\", $decoded);\n $this->assertStringNotContainsString(''', $decoded);\n $this->assertStringNotContainsString('&#039;', $decoded);\n $this->assertStringContainsString('<br>', $decoded);\n $this->assertStringContainsString('<', $decoded);\n $this->assertStringContainsString('>', $decoded);\n\n return 'note-complex';\n }\n\n return 'link-complex';\n });\n\n $complexBody = \"Summary:\\n---------\\nThe call focused on understanding Welch's current challenges and exploring how Arctics' Revenue Growth Management solutions could support their strategic goals.\\n\\n• John SMith discussed his role as a category advisor for Google and Facebook's Club, emphasizing the importance of market research and advising on product assortment.\\n• Madona introduced Arctics' Virtual Shoppers AI, which simulates consumer <behavior> to optimize pricing and promotional strategies.\";\n\n $result = $this->invokePrivateMethod('buildEnhancedNote', $service, [\n 'Jiminny Transcription Summary',\n $complexBody,\n 'task-id-001',\n ]);\n\n $this->assertEquals('note-complex', $result);\n }\n\n public function testSyncRemotelyDeletedObjectsWithErrorHandlingSuccess(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['syncRemotelyDeletedObjects'])\n ->getMock();\n\n // Mock team\n $team = $this->createMock(Team::class);\n $team->method('getUuid')->willReturn('team-uuid-123');\n $serviceMock->team = $team;\n\n // Expect syncRemotelyDeletedObjects to be called once and succeed\n $serviceMock->expects($this->once())\n ->method('syncRemotelyDeletedObjects')\n ->with(\\Jiminny\\Enums\\CrmObject::ACCOUNT);\n\n // Call the protected method using reflection\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');\n $method->setAccessible(true);\n\n // Should not throw any exceptions\n $method->invoke($serviceMock, \\Jiminny\\Enums\\CrmObject::ACCOUNT);\n\n $this->assertTrue(true); // Test completed successfully\n }\n\n public function testSyncRemotelyDeletedObjectsWithErrorHandlingFailure(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['syncRemotelyDeletedObjects'])\n ->getMock();\n\n // Mock team\n $team = $this->createMock(Team::class);\n $team->method('getUuid')->willReturn('team-uuid-456');\n $serviceMock->team = $team;\n\n // Mock logger to verify warning is logged\n $logger = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n\n // Use reflection to set the protected logger property\n $reflection = new \\ReflectionClass($serviceMock);\n $loggerProperty = $reflection->getProperty('logger');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($serviceMock, $logger);\n\n $exception = new \\Exception('Sync failed due to API error');\n\n // Expect syncRemotelyDeletedObjects to throw an exception\n $serviceMock->expects($this->once())\n ->method('syncRemotelyDeletedObjects')\n ->with(\\Jiminny\\Enums\\CrmObject::CONTACT)\n ->willThrowException($exception);\n\n // Expect warning to be logged with correct message and parameters\n $logger->expects($this->once())\n ->method('warning')\n ->with(\n '[Salesforce] Remotely deleted objects sync failed',\n [\n 'objectType' => 'contact',\n 'teamId' => 'team-uuid-456',\n 'reason' => 'Sync failed due to API error',\n ]\n );\n\n // Call the protected method using reflection\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');\n $method->setAccessible(true);\n\n // Should not re-throw the exception, just log it\n $method->invoke($serviceMock, \\Jiminny\\Enums\\CrmObject::CONTACT);\n\n $this->assertTrue(true); // Test completed successfully\n }\n\n public function testSyncRemotelyDeletedObjectsWithErrorHandlingWithLogParams(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['syncRemotelyDeletedObjects'])\n ->getMock();\n\n // Mock team\n $team = $this->createMock(Team::class);\n $team->method('getUuid')->willReturn('team-uuid-789');\n $serviceMock->team = $team;\n\n // Mock logger to verify warning is logged\n $logger = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n\n // Use reflection to set the protected logger property\n $loggerReflection = new \\ReflectionClass($serviceMock);\n $loggerProperty = $loggerReflection->getProperty('logger');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($serviceMock, $logger);\n\n $exception = new \\Exception('Network timeout');\n\n // Expect syncRemotelyDeletedObjects to throw an exception\n $serviceMock->expects($this->once())\n ->method('syncRemotelyDeletedObjects')\n ->with(\\Jiminny\\Enums\\CrmObject::OPPORTUNITY)\n ->willThrowException($exception);\n\n // Additional log parameters\n $logParams = [\n 'syncType' => 'full',\n 'batchSize' => 100,\n ];\n\n // Expect warning to be logged with merged parameters\n $logger->expects($this->once())\n ->method('warning')\n ->with(\n '[Salesforce] Remotely deleted objects sync failed',\n [\n 'objectType' => 'opportunity',\n 'teamId' => 'team-uuid-789',\n 'reason' => 'Network timeout',\n 'syncType' => 'full',\n 'batchSize' => 100,\n ]\n );\n\n // Call the protected method using reflection\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');\n $method->setAccessible(true);\n\n // Should not re-throw the exception, just log it\n $method->invoke($serviceMock, \\Jiminny\\Enums\\CrmObject::OPPORTUNITY, $logParams);\n\n $this->assertTrue(true); // Test completed successfully\n }\n\n /**\n * @dataProvider crmObjectProvider\n */\n public function testSyncRemotelyDeletedObjectsWithErrorHandlingDifferentCrmObjects(\\Jiminny\\Enums\\CrmObject $crmObject): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['syncRemotelyDeletedObjects'])\n ->getMock();\n\n // Mock team\n $team = $this->createMock(Team::class);\n $team->method('getUuid')->willReturn('team-uuid-test');\n $serviceMock->team = $team;\n\n // Mock logger to verify warning is logged\n $logger = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n\n // Use reflection to set the protected logger property\n $loggerReflectionClass = new \\ReflectionClass($serviceMock);\n $loggerProperty = $loggerReflectionClass->getProperty('logger');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($serviceMock, $logger);\n\n $exception = new \\Exception('Test error');\n\n // Expect syncRemotelyDeletedObjects to throw an exception\n $serviceMock->expects($this->once())\n ->method('syncRemotelyDeletedObjects')\n ->with($crmObject)\n ->willThrowException($exception);\n\n // Expect warning to be logged with correct entity type\n $logger->expects($this->once())\n ->method('warning')\n ->with(\n '[Salesforce] Remotely deleted objects sync failed',\n [\n 'objectType' => $crmObject->value,\n 'teamId' => 'team-uuid-test',\n 'reason' => 'Test error',\n ]\n );\n\n // Call the protected method using reflection\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');\n $method->setAccessible(true);\n\n $method->invoke($serviceMock, $crmObject);\n\n $this->assertTrue(true); // Test completed successfully\n }\n\n public function testHandleObjectDeletionWithDeletedEntity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['deleteCrmObject'])\n ->getMock();\n\n $entity = $this->createMock(\\Jiminny\\Models\\Account::class);\n $crmData = ['IsDeleted' => true];\n\n $serviceMock->expects($this->once())\n ->method('deleteCrmObject')\n ->with($entity);\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('handleObjectDeletion');\n $method->setAccessible(true);\n\n $method->invoke($serviceMock, $entity, $crmData);\n }\n\n public function testHandleObjectDeletionWithNonDeletedEntity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['deleteCrmObject'])\n ->getMock();\n\n $entity = $this->createMock(\\Jiminny\\Models\\Contact::class);\n $crmData = ['IsDeleted' => false];\n\n $serviceMock->expects($this->never())\n ->method('deleteCrmObject');\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('handleObjectDeletion');\n $method->setAccessible(true);\n\n $method->invoke($serviceMock, $entity, $crmData);\n }\n\n public function testDeleteCrmObjectWithValidEntity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['dispatchDeleteCrmObjectJob'])\n ->getMock();\n\n $entity = $this->createMock(\\Jiminny\\Models\\Lead::class);\n $entity->expects($this->once())->method('delete');\n\n $serviceMock->expects($this->once())\n ->method('dispatchDeleteCrmObjectJob')\n ->with($entity);\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('deleteCrmObject');\n $method->setAccessible(true);\n\n $method->invoke($serviceMock, $entity);\n }\n\n public function testDeleteCrmObjectWithNullEntity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['dispatchDeleteCrmObjectJob'])\n ->getMock();\n\n $serviceMock->expects($this->never())\n ->method('dispatchDeleteCrmObjectJob');\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('deleteCrmObject');\n $method->setAccessible(true);\n\n $method->invoke($serviceMock, null);\n }\n\n public function testDispatchDeleteCrmObjectJobWithNullEntity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($service);\n $method = $reflection->getMethod('dispatchDeleteCrmObjectJob');\n $method->setAccessible(true);\n\n // Should return early without dispatching - no exception expected\n $method->invoke($service, null);\n\n $this->assertTrue(true); // Test completed successfully\n }\n\n public function testDispatchDeleteCrmObjectJobWithUnsupportedEntity(): void\n {\n $this->expectException(\\TypeError::class);\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n $unsupportedEntity = $this->createMock(\\stdClass::class);\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($service);\n $method = $reflection->getMethod('dispatchDeleteCrmObjectJob');\n\n // This will throw TypeError due to union type constraint\n $method->invoke($service, $unsupportedEntity);\n }\n\n public function testHandleEntityDeletionByProviderIdMethodExists(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n // Test that the method exists and is accessible via reflection\n $reflection = new \\ReflectionClass($service);\n $method = $reflection->getMethod('handleEntityDeletionByProviderId');\n $method->setAccessible(true);\n\n // Verify method exists and has correct parameters\n $this->assertTrue($method->isProtected());\n $this->assertEquals(2, $method->getNumberOfParameters());\n\n $parameters = $method->getParameters();\n $this->assertEquals('targetEntity', $parameters[0]->getName());\n $this->assertEquals('crmData', $parameters[1]->getName());\n }\n\n public function testSyncRemotelyDeletedObjectsWithNoResults(): void\n {\n // Create a real service instance to avoid mock property issues\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n // Mock queryHandler to throw NoResultsException\n $queryHandler = $this->createMock(QueryHandler::class);\n $queryHandler->expects($this->once())\n ->method('queryDeleted')\n ->with('Opportunity')\n ->willThrowException(new NoResultsException('No results'));\n\n // Set the queryHandler using reflection on the real service\n $reflection = new \\ReflectionClass($service);\n $queryHandlerProperty = $reflection->getProperty('queryHandler');\n $queryHandlerProperty->setAccessible(true);\n $queryHandlerProperty->setValue($service, $queryHandler);\n\n $result = self::invokePrivateMethod('syncRemotelyDeletedObjects', $service, [CrmObject::OPPORTUNITY]);\n\n $this->assertFalse($result);\n }\n\n public function testSyncRemotelyDeletedObjectsWithEmptyResults(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n // Mock queryHandler to return empty results\n $queryResult = $this->createMock(QueryResults::class);\n $queryResult->method('getResults')->willReturn([]);\n\n $queryHandler = $this->createMock(QueryHandler::class);\n $queryHandler->expects($this->once())\n ->method('queryDeleted')\n ->with('Opportunity')\n ->willReturn($queryResult);\n\n // Set the queryHandler using reflection on the real service\n $reflection = new \\ReflectionClass($service);\n $queryHandlerProperty = $reflection->getProperty('queryHandler');\n $queryHandlerProperty->setAccessible(true);\n $queryHandlerProperty->setValue($service, $queryHandler);\n\n $result = self::invokePrivateMethod('syncRemotelyDeletedObjects', $service, [CrmObject::OPPORTUNITY]);\n\n $this->assertFalse($result);\n }\n\n public function testSyncRemotelyDeletedObjectsWithUnsupportedCrmObject(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n // Mock queryHandler to return some deleted objects so we reach the match statement\n $deletedObjects = [\n ['id' => 'task1'],\n ['id' => 'task2'],\n ];\n $queryResult = $this->createMock(QueryResults::class);\n $queryResult->method('getResults')->willReturn($deletedObjects);\n\n $queryHandler = $this->createMock(QueryHandler::class);\n $queryHandler->expects($this->once())\n ->method('queryDeleted')\n ->with('Task') // ucfirst('task') = 'Task'\n ->willReturn($queryResult);\n\n self::setPrivateProperty($service, 'queryHandler', $queryHandler);\n\n $this->expectException(InvalidArgumentException::class);\n $this->expectExceptionMessage('Unsupported CrmObject: task');\n\n self::invokePrivateMethod('syncRemotelyDeletedObjects', $service, [CrmObject::TASK]);\n }\n\n public static function crmObjectProvider(): array\n {\n return [\n 'Account' => [CrmObject::ACCOUNT],\n 'Contact' => [CrmObject::CONTACT],\n 'Lead' => [CrmObject::LEAD],\n 'Opportunity' => [CrmObject::OPPORTUNITY],\n ];\n }\n\n public function testVerifyTaskExistsReturnsTrue(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-123', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-123');\n $activity->method('getId')->willReturn(456);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-123', ['Id', 'IsDeleted'])\n ->willReturn(['Id' => 'task-123', 'IsDeleted' => false]);\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertTrue($result);\n }\n\n public function testVerifyTaskExistsReturnsTrueForEvent(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:event-123', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('event-123');\n $activity->method('getId')->willReturn(456);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_EVENT);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Event', 'event-123', ['Id', 'IsDeleted'])\n ->willReturn(['Id' => 'event-123', 'IsDeleted' => false]);\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertTrue($result);\n }\n\n public function testVerifyTaskExistsReturnsFalseWhenDeleted(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-456', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-456');\n $activity->method('getId')->willReturn(789);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-456', ['Id', 'IsDeleted'])\n ->willReturn(['Id' => 'task-456', 'IsDeleted' => true]);\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertFalse($result);\n }\n\n public function testVerifyTaskExistsReturnsFalseWhenNotFound(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-999', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-999');\n $activity->method('getId')->willReturn(999);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-999', ['Id', 'IsDeleted'])\n ->willThrowException(new \\Jiminny\\Exceptions\\HttpNotFoundException('Task not found'));\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertFalse($result);\n }\n\n public function testVerifyTaskExistsReturnsFalseWhenNoPlaybook(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-no-playbook', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-no-playbook');\n $activity->method('getId')->willReturn(111);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn(null);\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertFalse($result);\n }\n\n public function testVerifyTaskExistsThrowsExceptionForTransientErrors(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-error', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-error');\n $activity->method('getId')->willReturn(888);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-error', ['Id', 'IsDeleted'])\n ->willThrowException(new \\RuntimeException('Network timeout'));\n\n $this->expectException(\\RuntimeException::class);\n $this->expectExceptionMessage('Network timeout');\n\n $service->verifyTaskExists($activity);\n }\n\n public function testVerifyTaskExistsCachesResults(): void\n {\n $cachedValue = null;\n Cache::shouldReceive('remember')\n ->twice()\n ->with('crm_task_exists:123:task-cached', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(function ($key, $ttl, $callback) use (&$cachedValue) {\n if ($cachedValue === null) {\n $cachedValue = $callback();\n }\n\n return $cachedValue;\n });\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-cached');\n $activity->method('getId')->willReturn(555);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-cached', ['Id', 'IsDeleted'])\n ->willReturn(['Id' => 'task-cached', 'IsDeleted' => false]);\n\n $result1 = $service->verifyTaskExists($activity);\n $result2 = $service->verifyTaskExists($activity);\n\n $this->assertTrue($result1);\n $this->assertTrue($result2);\n }\n\n public function testVerifyTaskExistsReturnsFalseForHttpBadRequestException(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-400', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-400');\n $activity->method('getId')->willReturn(400);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-400', ['Id', 'IsDeleted'])\n ->willThrowException(new \\Jiminny\\Exceptions\\HttpBadRequestException('Bad request'));\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertFalse($result);\n }\n\n public function testImportOpportunitySkipsWhenNoProfileAndNoAccount(): void\n {\n $crmData = [\n 'Id' => 'SF-NO-USER-1',\n 'Name' => 'Test Opportunity',\n 'OwnerId' => 'owner-no-profile',\n // No AccountId\n ];\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(\\Illuminate\\Events\\Dispatcher::class); // ← ADD THIS\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n\n $service = new Service(\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService\n );\n\n $config = $this->createMock(Configuration::class);\n\n // Mock profiles relation returning null (no profile found)\n $profilesRelation = \\Mockery::mock(\\Illuminate\\Database\\Eloquent\\Relations\\HasMany::class);\n $profilesRelation->shouldReceive('where')->with('crm_provider_id', 'owner-no-profile')->andReturnSelf();\n $profilesRelation->shouldReceive('first')->andReturn(null);\n\n $config->expects($this->once())\n ->method('profiles')\n ->willReturn($profilesRelation);\n\n $team = $this->createMock(Team::class);\n $team->method('getId')->willReturn(1);\n\n $logger = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n $logger->expects($this->once())\n ->method('error')\n ->with(\n '[Salesforce] | Skip import, no user_id found',\n ['id' => 'SF-NO-USER-1']\n );\n\n $reflection = new \\ReflectionClass($service);\n\n $configProperty = $reflection->getProperty('config');\n $configProperty->setAccessible(true);\n $configProperty->setValue($service, $config);\n\n $teamProperty = $reflection->getProperty('team');\n $teamProperty->setAccessible(true);\n $teamProperty->setValue($service, $team);\n\n $loggerProperty = $reflection->getProperty('logger');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($service, $logger);\n\n // Initialize profile property to avoid \"must not be accessed before initialization\" error\n $profileProperty = $reflection->getProperty('profile');\n $profileProperty->setAccessible(true);\n $profileProperty->setValue($service, null);\n\n $result = self::invokePrivateMethod('importOpportunity', $service, [$crmData]);\n\n $this->assertNull($result);\n }\n\n public function testImportOpportunitySkipsWhenProfileNotFound(): void\n {\n $crmData = [\n 'Id' => 'SF-NO-USER-2',\n 'Name' => 'Test Opportunity',\n 'OwnerId' => 'owner-not-found',\n // No AccountId - avoid complex account processing\n ];\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $eventDispatcher = $this->createMock(\\Illuminate\\Events\\Dispatcher::class); // ← ADD THIS\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n\n\n $service = new Service(\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService\n );\n\n $config = $this->createMock(Configuration::class);\n\n // Mock profiles relation returning null (no profile found)\n $profilesRelation = \\Mockery::mock(\\Illuminate\\Database\\Eloquent\\Relations\\HasMany::class);\n $profilesRelation->shouldReceive('where')->with('crm_provider_id', 'owner-not-found')->andReturnSelf();\n $profilesRelation->shouldReceive('first')->andReturn(null);\n\n $config->expects($this->once())\n ->method('profiles')\n ->willReturn($profilesRelation);\n\n $team = $this->createMock(Team::class);\n $team->method('getId')->willReturn(1);\n\n $logger = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n $logger->expects($this->once())\n ->method('error')\n ->with(\n '[Salesforce] | Skip import, no user_id found',\n ['id' => 'SF-NO-USER-2']\n );\n\n $reflection = new \\ReflectionClass($service);\n\n $configProperty = $reflection->getProperty('config');\n $configProperty->setAccessible(true);\n $configProperty->setValue($service, $config);\n\n $teamProperty = $reflection->getProperty('team');\n $teamProperty->setAccessible(true);\n $teamProperty->setValue($service, $team);\n\n $loggerProperty = $reflection->getProperty('logger');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($service, $logger);\n\n // Initialize profile property\n $profileProperty = $reflection->getProperty('profile');\n $profileProperty->setAccessible(true);\n $profileProperty->setValue($service, null);\n\n $result = self::invokePrivateMethod('importOpportunity', $service, [$crmData]);\n\n $this->assertNull($result);\n }\n}\n\nclass HasManyExtended extends HasMany\n{\n public function where()\n {\n }\n\n public function first()\n {\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Services\\Crm\\Salesforce;\n\nuse Illuminate\\Database\\Eloquent\\Relations\\HasMany;\nuse Illuminate\\Events\\Dispatcher;\nuse Illuminate\\Support\\Facades\\Cache;\nuse Jiminny\\Component\\Country\\CountriesMap;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Enums\\CrmObject;\nuse Jiminny\\Exceptions\\InvalidArgumentException;\nuse Jiminny\\Exceptions\\NoResultsException;\nuse Jiminny\\Integrations\\PlaybookResolver;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Crm\\Configuration;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\TeamSettings;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\ContactRoleRepository;\nuse Jiminny\\Repositories\\Crm\\FieldDataRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\Salesforce\\Client;\nuse Jiminny\\Services\\Crm\\Salesforce\\PayloadBuilder;\nuse Jiminny\\Services\\Crm\\Salesforce\\QueryBuilder;\nuse Jiminny\\Services\\Crm\\Salesforce\\QueryHandler;\nuse Jiminny\\Services\\Crm\\Salesforce\\QueryIterator;\nuse Jiminny\\Services\\Crm\\Salesforce\\QueryResults;\nuse Jiminny\\Services\\Crm\\Salesforce\\Service;\nuse PHPUnit\\Framework\\MockObject\\MockObject;\nuse Tests\\TestCase;\nuse Tests\\Unit\\Traits\\TestPrivateMethod;\n\nclass ServiceTest extends TestCase\n{\n use TestPrivateMethod;\n\n public function testFetchAndAssociateRelatedActivity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $payloadBuilder->method('addCustomLogicFieldsPayload')\n ->willReturnCallback(function ($activity, $payload) {\n return $payload;\n });\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods(['fetchRelatedActivity', 'getPlaybook', 'getPlaybookCategory', 'updateRecord'])\n ->getMock();\n\n $serviceMock->expects($this->once())\n ->method('fetchRelatedActivity')\n ->willReturn([\n 'Id' => 'testId',\n 'Type' => null,\n 'OwnerId' => 'testerUser',\n 'Description' => 'Test description',\n ]);\n\n $user = $this->createMock(User::class);\n $team = $this->createMock(Team::class);\n $user->method('getAttribute')->with('team')->willReturn($team);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityField')->willReturn(null);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_EVENT);\n\n $serviceMock->expects($this->once())\n ->method('getPlaybook')\n ->with($user)\n ->willReturn($playbook);\n\n $serviceMock->expects($this->never())\n ->method('getPlaybookCategory');\n\n $serviceMock->expects($this->never())\n ->method('updateRecord');\n\n $fieldDataRepository = $this->createMock(FieldDataRepository::class);\n $fieldDataRepository->method('getActivityFieldData')->willReturn(collect([]));\n app()->instance(FieldDataRepository::class, $fieldDataRepository);\n\n $config = $this->createMock(Configuration::class);\n $profilesRelation = $this->getMockBuilder(\\Illuminate\\Database\\Eloquent\\Relations\\HasMany::class)\n ->disableOriginalConstructor()\n ->onlyMethods(['get'])\n ->addMethods(['where'])\n ->getMock();\n $profilesRelation->method('where')->willReturnSelf();\n $profilesRelation->method('get')->willReturn(collect([]));\n $config->method('profiles')->willReturn($profilesRelation);\n\n $serviceMock->config = $config;\n $serviceMock->profile = null;\n\n $actualStartTime = \\Carbon\\Carbon::now();\n\n $activity = $this->getMockBuilder(Activity::class)\n ->disableOriginalConstructor()\n ->onlyMethods(['update', 'hasProspect'])\n ->getMock();\n\n $activity->method('update')->willReturn(true);\n $activity->method('hasProspect')->willReturn(true);\n\n $activity->type = Activity::TYPE_CONFERENCE;\n $activity->provider = Activity::PROVIDER_TWILIO;\n $activity->lead_id = 1;\n $activity->user_id = 0;\n $activity->id_string = 'test-activity-id';\n $activity->user = $user;\n\n $activity->actual_start_time = $actualStartTime;\n $activity->uuid = 'c53d8320-f556-4cee-a2f8-5f232f454ca4';\n\n app()->bind(PlaybookResolver::class, function () use ($user) {\n $playbook = $this->createMock(Playbook::class);\n $playbookResolver = $this->createMock(PlaybookResolver::class);\n $playbookResolver->expects($this->once())\n ->method('resolvePlaybookByUser')\n ->with($user)\n ->willReturn($playbook);\n\n return $playbookResolver;\n });\n\n $data = $serviceMock->fetchAndAssociateRelatedActivity($activity);\n\n $this->assertInstanceOf(Activity::class, $data);\n $this->assertEquals(Activity::TYPE_CONFERENCE, $data->getType());\n $this->assertEquals($actualStartTime->getTimestamp(), $data->getActualStartTime()->getTimestamp());\n }\n\n public function testFetchAndAssociateRelatedActivitySkipsForTaskBasedPlaybook(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods(['fetchRelatedActivity', 'getPlaybook'])\n ->getMock();\n\n $user = $this->createMock(User::class);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n $playbook->method('getId')->willReturn(123);\n\n $serviceMock->expects($this->once())\n ->method('getPlaybook')\n ->with($user)\n ->willReturn($playbook);\n\n $serviceMock->expects($this->never())\n ->method('fetchRelatedActivity');\n\n $activity = $this->getMockBuilder(Activity::class)\n ->disableOriginalConstructor()\n ->onlyMethods(['hasProspect', 'getUuid'])\n ->getMock();\n\n $activity->method('hasProspect')->willReturn(true);\n $activity->method('getUuid')->willReturn('c53d8320-f556-4cee-a2f8-5f232f454ca4');\n $activity->type = Activity::TYPE_CONFERENCE;\n $activity->actual_start_time = \\Carbon\\Carbon::now();\n $activity->user = $user;\n\n $result = $serviceMock->fetchAndAssociateRelatedActivity($activity);\n\n $this->assertNull($result);\n }\n\n public function testFetchAndAssociateRelatedActivityReturnsNullForNonConference(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $serviceMock = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)\n );\n\n $activity = $this->getMockBuilder(Activity::class)\n ->disableOriginalConstructor()\n ->getMock();\n\n $activity->type = Activity::TYPE_SOFTPHONE;\n\n $result = $serviceMock->fetchAndAssociateRelatedActivity($activity);\n\n $this->assertNull($result);\n }\n\n public function testFetchAndAssociateRelatedActivityReturnsNullWhenNoStartTime(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $serviceMock = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)\n );\n\n\n $activity = $this->getMockBuilder(Activity::class)\n ->disableOriginalConstructor()\n ->getMock();\n\n $activity->type = Activity::TYPE_CONFERENCE;\n $activity->actual_start_time = null;\n $activity->scheduled_start_time = null;\n\n $result = $serviceMock->fetchAndAssociateRelatedActivity($activity);\n\n $this->assertNull($result);\n }\n\n public function testFetchAndAssociateRelatedActivityReturnsNullWhenNoProspect(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods(['getPlaybook'])\n ->getMock();\n\n $serviceMock->expects($this->never())\n ->method('getPlaybook');\n\n $activity = $this->getMockBuilder(Activity::class)\n ->disableOriginalConstructor()\n ->onlyMethods(['hasProspect', 'getUuid'])\n ->getMock();\n\n $activity->method('hasProspect')->willReturn(false);\n $activity->method('getUuid')->willReturn('c53d8320-f556-4cee-a2f8-5f232f454ca4');\n $activity->type = Activity::TYPE_CONFERENCE;\n $activity->actual_start_time = \\Carbon\\Carbon::now();\n\n $result = $serviceMock->fetchAndAssociateRelatedActivity($activity);\n\n $this->assertNull($result);\n }\n\n public function testMatchExactlyByEmail(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods([])\n ->getMock();\n\n $profile = new Profile();\n $profile->setAttribute('id', bin2hex(random_bytes(8)));\n $serviceMock->profile = $profile;\n\n $team = $this->createMock(Team::class);\n $serviceMock->team = $team;\n\n $data = $serviceMock->matchExactlyByEmail(bin2hex(random_bytes(8)) . 'test_email@testserver.com');\n\n $this->assertEquals(null, $data);\n }\n\n public function testMatchDomainFromEmail(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $queryIterator = $this->createMock(QueryIterator::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $config = $this->createMock(Configuration::class);\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('search')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods(['convertCrmData'])\n ->getMock();\n\n $profile = new Profile();\n $profile->account_fields = 'Field1, Field2, Field3';\n $serviceMock->profile = $profile;\n\n $serviceMock->expects($this->once())\n ->method('convertCrmData')\n ->willReturn(['test']);\n\n $this->app->bind(QueryBuilder::class, function () {\n $queryBuilder = $this->createMock(QueryBuilder::class);\n $queryBuilder->expects($this->once())\n ->method('buildMatchByDomainQuery')\n ->with('test_email@testserver.com')\n ->willReturn('FIND {test_email@testserver.com} IN ALL FIELDS RETURNING Account(Id)');\n\n return $queryBuilder;\n });\n\n $team = $this->createMock(Team::class);\n\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n\n $data = $serviceMock->matchByDomain('test_email@testserver.com');\n\n $this->assertEquals(['test'], $data);\n }\n\n public function testBuildTaskSearchFields(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)\n );\n\n $fields = $service->buildTaskSearchFields();\n\n $expectedFields = ['Id', 'WhoId', 'WhatId', 'AccountId'];\n\n $this->assertEquals($expectedFields, $fields);\n }\n\n public function testMapCrmObjects(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class),\n );\n\n $sampleTask = [\n 'WhoId' => '003sampleWhoId',\n 'AccountId' => 'sampleAccountId',\n 'WhatId' => 'sampleWhatId',\n ];\n\n $activityData = $service->mapCrmObjects($sampleTask);\n\n $expectedActivityData = [\n 'contact' => '003sampleWhoId',\n 'account' => 'sampleAccountId',\n 'opportunity' => 'sampleWhatId',\n ];\n\n $this->assertEquals($expectedActivityData, $activityData);\n }\n\n public function testGetInstalledAppVersion(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n $queryIterator = $this->createMock(QueryIterator::class);\n $queryIterator->expects($this->any())\n ->method('current')->willReturn([\n 'SubscriberPackageVersion' => [\n 'MajorVersion' => '1',\n 'MinorVersion' => '0',\n 'PatchVersion' => '1',\n 'BuildNumber' => '0',\n ],\n ]);\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('metadata')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods(array_diff(get_class_methods(Service::class), ['getInstalledAppVersion']))\n ->getMock();\n\n $version = $serviceMock->getInstalledAppVersion();\n\n $this->assertEquals('1010', $version);\n }\n\n public function testSyncProfiles(): void\n {\n $userToSearch = null;\n\n $team = $this->createMock(Team::class);\n $config = $this->createMockedConfiguration();\n $config->expects($this->once())\n ->method('getId')\n ->willReturn(1);\n\n $salesforceUser = [\n 'Email' => 'test@example.com',\n 'UserPreferencesLightningExperiencePreferred' => true,\n 'CallCenterId' => '123',\n 'Id' => '456',\n 'ProfileId' => '789',\n ];\n\n app()->bind(QueryBuilder::class, function () use ($userToSearch) {\n $queryBuilder = $this->createMock(QueryBuilder::class);\n $queryBuilder->expects($this->once())\n ->method('buildGetUsersQuery')\n ->with($userToSearch)\n ->willReturn('SELECT * FROM Users');\n\n return $queryBuilder;\n });\n\n $queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('query')\n ->with('SELECT * FROM Users')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n\n $user = $this->createMock(User::class);\n $user->expects($this->once())\n ->method('getId')\n ->willReturn(123);\n\n $this->mockTeamRepository($team, $salesforceUser, $user);\n\n $profileRepository = $this->createMock(ProfileRepository::class);\n $profileRepository->expects($this->once())\n ->method('updateOrCreateProfile')\n ->with(\n $user,\n [\n 'crm_configuration_id' => 1,\n 'crm_provider_id' => '456',\n ],\n [\n 'user_id' => 123,\n 'edition' => Profile::EDITION_LIGHTNING,\n 'has_external_cti' => true,\n 'crm_profile_id' => '789',\n ]\n )\n ->willReturn(new Profile());\n\n $this->app->instance(ProfileRepository::class, $profileRepository);\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n $result = $serviceMock->syncProfiles($userToSearch);\n\n $this->assertNull($result);\n }\n\n public function testSyncProfilesEmailIsNull(): void\n {\n $userToSearch = $this->createMock(User::class);\n\n $salesforceUser = [\n 'Email' => null,\n ];\n\n app()->bind(QueryBuilder::class, function () {\n $queryBuilder = $this->createMock(QueryBuilder::class);\n $queryBuilder->expects($this->once())\n ->method('buildGetUsersQuery')\n ->with(null)\n ->willReturn('SELECT * FROM Users');\n\n return $queryBuilder;\n });\n\n $queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->exactly(2))\n ->method('query')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n\n $team = $this->createMock(Team::class);\n $user = $this->createMock(User::class);\n $this->mockTeamRepository($team, $salesforceUser, $user, false);\n\n $config = $this->createMock(Configuration::class);\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n\n $profile = $serviceMock->syncProfiles(null);\n\n $this->assertNull($profile);\n }\n\n public function testSyncProfilesUserToSearchMatchesCurrentUser(): void\n {\n $userToSearch = $this->createMock(User::class);\n $userToSearch->expects($this->once())\n ->method('getId')\n ->willReturn(123);\n\n $team = $this->createMock(Team::class);\n $config = $this->createMock(Configuration::class);\n $config->expects($this->once())\n ->method('getId')\n ->willReturn(1);\n\n $salesforceUser = [\n 'Email' => 'test@example.com',\n 'UserPreferencesLightningExperiencePreferred' => true,\n 'CallCenterId' => '123',\n 'Id' => '456',\n 'ProfileId' => '789',\n ];\n\n app()->bind(QueryBuilder::class, function () use ($userToSearch) {\n $queryBuilder = $this->createMock(QueryBuilder::class);\n $queryBuilder->expects($this->once())\n ->method('buildGetUsersQuery')\n ->with($userToSearch)\n ->willReturn('SELECT * FROM Users');\n\n return $queryBuilder;\n });\n\n $queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('query')\n ->with('SELECT * FROM Users')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n\n $user = $this->createMock(User::class);\n $user->expects($this->exactly(2))\n ->method('getId')\n ->willReturn(123);\n\n $this->mockTeamRepository($team, $salesforceUser, $user);\n\n $profileRepository = $this->createMock(ProfileRepository::class);\n $profileRepository->expects($this->once())\n ->method('updateOrCreateProfile')\n ->with(\n $user,\n [\n 'crm_configuration_id' => 1,\n 'crm_provider_id' => '456',\n ],\n [\n 'user_id' => 123,\n 'edition' => Profile::EDITION_LIGHTNING,\n 'has_external_cti' => true,\n 'crm_profile_id' => '789',\n ]\n )\n ->willReturn(new Profile());\n\n $this->app->instance(ProfileRepository::class, $profileRepository);\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n $profile = $serviceMock->syncProfiles($userToSearch);\n\n $this->assertInstanceOf(Profile::class, $profile);\n }\n\n public function testSyncProfilesWithCustomValidation(): void\n {\n $userToSearch = null;\n\n $team = $this->createMock(Team::class);\n $config = $this->createMockedConfiguration();\n $config->expects($this->atLeastOnce()) // Changed from once() to atLeastOnce()\n ->method('getId')\n ->willReturn(1);\n\n $salesforceUser = [\n 'Email' => 'test@example.com',\n 'UserPreferencesLightningExperiencePreferred' => true,\n 'CallCenterId' => '123',\n 'Id' => '456',\n 'ProfileId' => '789',\n 'CustomField' => 'CustomValue',\n ];\n\n $customRules = [\n ['field' => 'CustomField', 'value' => 'CustomValue'],\n ];\n\n $this->mockQueryBuilderAndHandler($userToSearch, [$salesforceUser]);\n\n $user = $this->createMock(User::class);\n $user->expects($this->once())\n ->method('getId')\n ->willReturn(123);\n\n $this->mockTeamRepository($team, $salesforceUser, $user, true, $customRules);\n\n $this->mockProfileRepository($user);\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n $profile = $serviceMock->syncProfiles($userToSearch);\n\n $this->assertNull($profile);\n }\n\n public function testSyncProfilesWithCustomValidationFailing(): void\n {\n $userToSearch = null;\n\n $team = $this->createMock(Team::class);\n $config = $this->createMockedConfiguration();\n\n $salesforceUser = [\n 'Email' => 'test@example.com',\n 'UserPreferencesLightningExperiencePreferred' => true,\n 'CallCenterId' => '123',\n 'Id' => '456',\n 'ProfileId' => '789',\n 'CustomField' => 'WrongValue',\n ];\n\n $customRules = [\n ['field' => 'CustomField', 'value' => 'CustomValue'],\n ];\n\n $this->mockQueryBuilderAndHandler($userToSearch, [$salesforceUser]);\n\n $teamRepository = $this->getMockForAbstractClass(TeamRepository::class, [], '', false, true, true, ['findActiveTeamMemberByEmail', 'getTeamSetting']);\n\n $teamSettings = $this->createMock(TeamSettings::class);\n $teamSettings->method('getValueType')\n ->willReturn('array');\n\n $teamSettings->method('getValue')\n ->willReturn(json_encode($customRules));\n\n $teamRepository->expects($this->once())\n ->method('getTeamSetting')\n ->with($team, 'custom_profile_validation')\n ->willReturn($teamSettings);\n\n app()->bind(TeamRepository::class, function () use ($teamRepository) {\n return $teamRepository;\n });\n\n $profileRepository = $this->createMock(ProfileRepository::class);\n $profileRepository->expects($this->never())\n ->method('updateOrCreateProfile');\n\n $this->app->instance(ProfileRepository::class, $profileRepository);\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n $result = $serviceMock->syncProfiles($userToSearch);\n\n $this->assertNull($result);\n }\n\n public function testGetContactRolesFromCrm(): void\n {\n $contactRoles = [\n [\n 'Id' => '1',\n 'ContactId' => 'Contact1',\n 'OpportunityId' => 'Opportunity1',\n 'Opportunity' => ['OwnerId' => 'Owner1'],\n 'IsPrimary' => true,\n 'Role' => 'Decision Maker',\n ],\n ];\n\n $expectedResponse = [\n [\n 'id' => '1',\n 'contactId' => 'Contact1',\n 'opportunityId' => 'Opportunity1',\n 'ownerId' => 'Owner1',\n 'isPrimary' => true,\n 'role' => 'Decision Maker',\n ],\n ];\n\n $this->bindQueryIterator($contactRoles);\n\n $serviceMock = $this->getServiceMock();\n\n $data = $serviceMock->getContactRolesFromCrm(now()->subDay());\n\n $this->assertEquals($expectedResponse, $data);\n }\n\n public function testGetContactRolesFromCrmNoResult(): void\n {\n $this->bindQueryIterator([]);\n\n $serviceMock = $this->getServiceMock();\n\n $data = $serviceMock->getContactRolesFromCrm(now()->subDay());\n\n $this->assertEquals([], $data);\n }\n\n public function testSyncContactRoles(): void\n {\n $contactRoles = [\n [\n 'id' => '1',\n 'contactId' => 'Contact1',\n 'opportunityId' => 'Opportunity1',\n 'ownerId' => 'Owner1',\n 'isPrimary' => true,\n 'role' => 'Decision Maker',\n ],\n ];\n\n app()->bind(ContactRoleRepository::class, function () {\n $contactRoleRepository = $this->createMock(ContactRoleRepository::class);\n $contactRoleRepository->expects($this->once())\n ->method('saveContactRoles');\n\n return $contactRoleRepository;\n });\n\n $serviceMock = $this->getServiceMock([\n 'getContactRolesFromCrm',\n 'syncRemotelyDeletedContactRoles',\n 'syncContact',\n 'syncOpportunity',\n ]);\n\n $config = $this->createMock(Configuration::class);\n $hasMany = $this->createMock(HasManyExtended::class);\n $hasMany->expects($this->exactly(2))\n ->method('where')\n ->willReturn($hasMany);\n\n $hasMany->expects($this->exactly(2))\n ->method('first')\n ->willReturn(\n $this->createMock(Contact::class),\n $this->createMock(Opportunity::class)\n );\n\n $config->expects($this->once())\n ->method('contacts')\n ->willReturn($hasMany);\n $config->expects($this->once())\n ->method('opportunities')\n ->willReturn($hasMany);\n\n $serviceMock->config = $config;\n\n $serviceMock->expects($this->once())\n ->method('getContactRolesFromCrm')\n ->willReturn($contactRoles);\n\n $serviceMock->expects($this->once())\n ->method('syncRemotelyDeletedContactRoles');\n\n $data = $serviceMock->syncContactRoles(now()->subDay());\n\n $this->assertEquals(1, $data);\n }\n\n public function testSyncRemotelyDeletedContactRoles(): void\n {\n $contactRoles = [\n [\n 'id' => '1',\n 'crm_provider_id' => '1',\n ],\n ];\n\n app()->bind(QueryHandler::class, function () use ($contactRoles) {\n $queryResults = new QueryResults($contactRoles, 1, true, null);\n\n $handler = $this->createMock(QueryHandler::class);\n $handler->method('queryDeleted')\n ->willReturn($queryResults);\n\n return $handler;\n });\n\n app()->bind(ContactRoleRepository::class, function () {\n $contactRoleRepository = $this->createMock(ContactRoleRepository::class);\n $contactRoleRepository->expects($this->once())\n ->method('deleteContactRoles');\n\n return $contactRoleRepository;\n });\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $this->createMock(Team::class);\n\n $data = $this->invokePrivateMethod('syncRemotelyDeletedContactRoles', $serviceMock, []);\n\n $this->assertTrue($data);\n }\n\n private function bindQueryIterator(array $queryResult): void\n {\n /** @var Client $client */\n $client = $this->createMock(Client::class);\n $queryIterator = new QueryIterator(\n $client,\n new QueryResults($queryResult, 1, true, null)\n );\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('query')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n }\n\n public static function getOpportunitySortOrderDataProvider(): array\n {\n return [\n 'all open recently updated' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED, ['LastModifiedDate DESC', true]],\n 'all open recently created' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED, ['CreatedDate DESC', true]],\n 'all open oldest created' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED, ['CreatedDate ASC', true]],\n 'all recently updated' => [Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED, ['LastModifiedDate DESC', false]],\n 'default' => ['unknown', ['LastModifiedDate DESC', true]],\n ];\n }\n\n private function createMockedConfiguration(): Configuration\n {\n $config = $this->createMock(Configuration::class);\n $profilesRelation = $this->getMockBuilder(\\Illuminate\\Database\\Eloquent\\Relations\\HasMany::class)\n ->disableOriginalConstructor()\n ->onlyMethods(['get'])\n ->addMethods(['where', 'first'])\n ->getMock();\n $profilesRelation->method('where')->willReturnSelf();\n $profilesRelation->method('get')->willReturn(collect([]));\n $profilesRelation->method('first')->willReturn(null);\n $config->method('profiles')->willReturn($profilesRelation);\n\n return $config;\n }\n\n private function getServiceMock(array $onlyMethods = []): MockObject&Service\n {\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $this->createMock(Client::class),\n $this->createMock(PayloadBuilder::class),\n $this->createMock(Dispatcher::class),\n $this->createMock(CountriesMap::class),\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods($onlyMethods)\n ->getMock();\n\n $serviceMock->profile = $this->createMock(Profile::class);\n\n return $serviceMock;\n }\n\n private function mockQueryBuilderAndHandler($userToSearch, $salesforceUsers): void\n {\n app()->bind(QueryBuilder::class, function () use ($userToSearch) {\n $queryBuilder = $this->createMock(QueryBuilder::class);\n $queryBuilder->expects($this->once())\n ->method('buildGetUsersQuery')\n ->with($userToSearch)\n ->willReturn('SELECT * FROM Users');\n\n return $queryBuilder;\n });\n\n $queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults($salesforceUsers, count($salesforceUsers), true, null));\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('query')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n }\n\n private function mockTeamRepository(\n Team $team,\n array $salesforceUser,\n ?User $user = null,\n bool $userSearch = true,\n array $customRules = []\n ): void {\n $teamRepository = $this->getMockForAbstractClass(TeamRepository::class, [], '', false, true, true, ['findActiveTeamMemberByEmail', 'getTeamSetting']);\n\n if ($userSearch) {\n $teamRepository->expects($this->once())\n ->method('findActiveTeamMemberByEmail')\n ->with($team, $salesforceUser['Email'])\n ->willReturn($user);\n }\n\n $teamSettings = $this->createMock(TeamSettings::class);\n $teamSettings->method('getValueType')\n ->willReturn('array');\n\n $teamSettings->method('getValue')\n ->willReturn(json_encode($customRules));\n\n $teamRepository->expects($this->once())\n ->method('getTeamSetting')\n ->with($team, 'custom_profile_validation')\n ->willReturn($teamSettings);\n\n app()->bind(TeamRepository::class, function () use ($teamRepository) {\n return $teamRepository;\n });\n }\n\n private function mockProfileRepository(User $user): void\n {\n $profileRepository = $this->createMock(ProfileRepository::class);\n $profileRepository->expects($this->once())\n ->method('updateOrCreateProfile')\n ->with(\n $user,\n [\n 'crm_configuration_id' => 1,\n 'crm_provider_id' => '456',\n ],\n [\n 'user_id' => 123,\n 'edition' => Profile::EDITION_LIGHTNING,\n 'has_external_cti' => true,\n 'crm_profile_id' => '789',\n ]\n )\n ->willReturn(new Profile());\n\n $this->app->instance(ProfileRepository::class, $profileRepository);\n }\n\n public function testBuildEnhancedNoteDecodesHtmlEntities(): void\n {\n $service = $this->getServiceMock(['createRecord']);\n\n $profile = new Profile();\n $profile->setAttribute('crm_provider_id', 'owner-123');\n $service->profile = $profile;\n\n $service->expects($this->exactly(2))\n ->method('createRecord')\n ->willReturnOnConsecutiveCalls('note-id-123', 'link-id-456');\n\n $bodyWithEntities = 'Welch's current challenges and Facebook's Club';\n\n $result = $this->invokePrivateMethod('buildEnhancedNote', $service, [\n 'Test Title',\n $bodyWithEntities,\n 'object-id-789',\n ]);\n\n $this->assertEquals('note-id-123', $result);\n }\n\n public function testBuildEnhancedNoteSanitizesWithoutQuotes(): void\n {\n $service = $this->getServiceMock(['createRecord']);\n\n $profile = new Profile();\n $profile->setAttribute('crm_provider_id', 'owner-456');\n $service->profile = $profile;\n\n $service->expects($this->exactly(2))\n ->method('createRecord')\n ->willReturnCallback(function ($type, $data) {\n if ($type === 'ContentNote') {\n $decoded = base64_decode($data['Content']);\n $this->assertStringContainsString(\"Welch's\", $decoded);\n $this->assertStringNotContainsString(''', $decoded);\n $this->assertStringNotContainsString('&#039;', $decoded);\n $this->assertStringContainsString('<script>', $decoded);\n\n return 'note-id-456';\n }\n\n return 'link-id-789';\n });\n\n $bodyWithMixedContent = \"Welch's and <script>alert('xss')</script>\";\n\n $result = $this->invokePrivateMethod('buildEnhancedNote', $service, [\n 'Test Title',\n $bodyWithMixedContent,\n 'object-id-123',\n ]);\n\n $this->assertEquals('note-id-456', $result);\n }\n\n public function testBuildEnhancedNoteConvertsLineBreaks(): void\n {\n $service = $this->getServiceMock(['createRecord']);\n\n $profile = new Profile();\n $profile->setAttribute('crm_provider_id', 'owner-789');\n $service->profile = $profile;\n\n $service->expects($this->exactly(2))\n ->method('createRecord')\n ->willReturnCallback(function ($type, $data) {\n if ($type === 'ContentNote') {\n $decoded = base64_decode($data['Content']);\n $this->assertStringContainsString('<br>', $decoded);\n $this->assertStringNotContainsString('<br />', $decoded);\n\n return 'note-id-789';\n }\n\n return 'link-id-012';\n });\n\n $bodyWithLineBreaks = \"Line 1\\nLine 2\\nLine 3\";\n\n $result = $this->invokePrivateMethod('buildEnhancedNote', $service, [\n 'Test Title',\n $bodyWithLineBreaks,\n 'object-id-456',\n ]);\n\n $this->assertEquals('note-id-789', $result);\n }\n\n public function testBuildEnhancedNoteHandlesComplexScenario(): void\n {\n $service = $this->getServiceMock(['createRecord']);\n\n $profile = new Profile();\n $profile->setAttribute('crm_provider_id', 'owner-complex');\n $service->profile = $profile;\n\n $service->expects($this->exactly(2))\n ->method('createRecord')\n ->willReturnCallback(function ($type, $data) {\n if ($type === 'ContentNote') {\n $decoded = base64_decode($data['Content']);\n\n $this->assertStringContainsString(\"Welch's\", $decoded);\n $this->assertStringContainsString(\"Facebook's Club\", $decoded);\n $this->assertStringContainsString(\"Arctics'\", $decoded);\n $this->assertStringNotContainsString(''', $decoded);\n $this->assertStringNotContainsString('&#039;', $decoded);\n $this->assertStringContainsString('<br>', $decoded);\n $this->assertStringContainsString('<', $decoded);\n $this->assertStringContainsString('>', $decoded);\n\n return 'note-complex';\n }\n\n return 'link-complex';\n });\n\n $complexBody = \"Summary:\\n---------\\nThe call focused on understanding Welch's current challenges and exploring how Arctics' Revenue Growth Management solutions could support their strategic goals.\\n\\n• John SMith discussed his role as a category advisor for Google and Facebook's Club, emphasizing the importance of market research and advising on product assortment.\\n• Madona introduced Arctics' Virtual Shoppers AI, which simulates consumer <behavior> to optimize pricing and promotional strategies.\";\n\n $result = $this->invokePrivateMethod('buildEnhancedNote', $service, [\n 'Jiminny Transcription Summary',\n $complexBody,\n 'task-id-001',\n ]);\n\n $this->assertEquals('note-complex', $result);\n }\n\n public function testSyncRemotelyDeletedObjectsWithErrorHandlingSuccess(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['syncRemotelyDeletedObjects'])\n ->getMock();\n\n // Mock team\n $team = $this->createMock(Team::class);\n $team->method('getUuid')->willReturn('team-uuid-123');\n $serviceMock->team = $team;\n\n // Expect syncRemotelyDeletedObjects to be called once and succeed\n $serviceMock->expects($this->once())\n ->method('syncRemotelyDeletedObjects')\n ->with(\\Jiminny\\Enums\\CrmObject::ACCOUNT);\n\n // Call the protected method using reflection\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');\n $method->setAccessible(true);\n\n // Should not throw any exceptions\n $method->invoke($serviceMock, \\Jiminny\\Enums\\CrmObject::ACCOUNT);\n\n $this->assertTrue(true); // Test completed successfully\n }\n\n public function testSyncRemotelyDeletedObjectsWithErrorHandlingFailure(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['syncRemotelyDeletedObjects'])\n ->getMock();\n\n // Mock team\n $team = $this->createMock(Team::class);\n $team->method('getUuid')->willReturn('team-uuid-456');\n $serviceMock->team = $team;\n\n // Mock logger to verify warning is logged\n $logger = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n\n // Use reflection to set the protected logger property\n $reflection = new \\ReflectionClass($serviceMock);\n $loggerProperty = $reflection->getProperty('logger');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($serviceMock, $logger);\n\n $exception = new \\Exception('Sync failed due to API error');\n\n // Expect syncRemotelyDeletedObjects to throw an exception\n $serviceMock->expects($this->once())\n ->method('syncRemotelyDeletedObjects')\n ->with(\\Jiminny\\Enums\\CrmObject::CONTACT)\n ->willThrowException($exception);\n\n // Expect warning to be logged with correct message and parameters\n $logger->expects($this->once())\n ->method('warning')\n ->with(\n '[Salesforce] Remotely deleted objects sync failed',\n [\n 'objectType' => 'contact',\n 'teamId' => 'team-uuid-456',\n 'reason' => 'Sync failed due to API error',\n ]\n );\n\n // Call the protected method using reflection\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');\n $method->setAccessible(true);\n\n // Should not re-throw the exception, just log it\n $method->invoke($serviceMock, \\Jiminny\\Enums\\CrmObject::CONTACT);\n\n $this->assertTrue(true); // Test completed successfully\n }\n\n public function testSyncRemotelyDeletedObjectsWithErrorHandlingWithLogParams(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['syncRemotelyDeletedObjects'])\n ->getMock();\n\n // Mock team\n $team = $this->createMock(Team::class);\n $team->method('getUuid')->willReturn('team-uuid-789');\n $serviceMock->team = $team;\n\n // Mock logger to verify warning is logged\n $logger = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n\n // Use reflection to set the protected logger property\n $loggerReflection = new \\ReflectionClass($serviceMock);\n $loggerProperty = $loggerReflection->getProperty('logger');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($serviceMock, $logger);\n\n $exception = new \\Exception('Network timeout');\n\n // Expect syncRemotelyDeletedObjects to throw an exception\n $serviceMock->expects($this->once())\n ->method('syncRemotelyDeletedObjects')\n ->with(\\Jiminny\\Enums\\CrmObject::OPPORTUNITY)\n ->willThrowException($exception);\n\n // Additional log parameters\n $logParams = [\n 'syncType' => 'full',\n 'batchSize' => 100,\n ];\n\n // Expect warning to be logged with merged parameters\n $logger->expects($this->once())\n ->method('warning')\n ->with(\n '[Salesforce] Remotely deleted objects sync failed',\n [\n 'objectType' => 'opportunity',\n 'teamId' => 'team-uuid-789',\n 'reason' => 'Network timeout',\n 'syncType' => 'full',\n 'batchSize' => 100,\n ]\n );\n\n // Call the protected method using reflection\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');\n $method->setAccessible(true);\n\n // Should not re-throw the exception, just log it\n $method->invoke($serviceMock, \\Jiminny\\Enums\\CrmObject::OPPORTUNITY, $logParams);\n\n $this->assertTrue(true); // Test completed successfully\n }\n\n /**\n * @dataProvider crmObjectProvider\n */\n public function testSyncRemotelyDeletedObjectsWithErrorHandlingDifferentCrmObjects(\\Jiminny\\Enums\\CrmObject $crmObject): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['syncRemotelyDeletedObjects'])\n ->getMock();\n\n // Mock team\n $team = $this->createMock(Team::class);\n $team->method('getUuid')->willReturn('team-uuid-test');\n $serviceMock->team = $team;\n\n // Mock logger to verify warning is logged\n $logger = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n\n // Use reflection to set the protected logger property\n $loggerReflectionClass = new \\ReflectionClass($serviceMock);\n $loggerProperty = $loggerReflectionClass->getProperty('logger');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($serviceMock, $logger);\n\n $exception = new \\Exception('Test error');\n\n // Expect syncRemotelyDeletedObjects to throw an exception\n $serviceMock->expects($this->once())\n ->method('syncRemotelyDeletedObjects')\n ->with($crmObject)\n ->willThrowException($exception);\n\n // Expect warning to be logged with correct entity type\n $logger->expects($this->once())\n ->method('warning')\n ->with(\n '[Salesforce] Remotely deleted objects sync failed',\n [\n 'objectType' => $crmObject->value,\n 'teamId' => 'team-uuid-test',\n 'reason' => 'Test error',\n ]\n );\n\n // Call the protected method using reflection\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');\n $method->setAccessible(true);\n\n $method->invoke($serviceMock, $crmObject);\n\n $this->assertTrue(true); // Test completed successfully\n }\n\n public function testHandleObjectDeletionWithDeletedEntity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['deleteCrmObject'])\n ->getMock();\n\n $entity = $this->createMock(\\Jiminny\\Models\\Account::class);\n $crmData = ['IsDeleted' => true];\n\n $serviceMock->expects($this->once())\n ->method('deleteCrmObject')\n ->with($entity);\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('handleObjectDeletion');\n $method->setAccessible(true);\n\n $method->invoke($serviceMock, $entity, $crmData);\n }\n\n public function testHandleObjectDeletionWithNonDeletedEntity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['deleteCrmObject'])\n ->getMock();\n\n $entity = $this->createMock(\\Jiminny\\Models\\Contact::class);\n $crmData = ['IsDeleted' => false];\n\n $serviceMock->expects($this->never())\n ->method('deleteCrmObject');\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('handleObjectDeletion');\n $method->setAccessible(true);\n\n $method->invoke($serviceMock, $entity, $crmData);\n }\n\n public function testDeleteCrmObjectWithValidEntity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['dispatchDeleteCrmObjectJob'])\n ->getMock();\n\n $entity = $this->createMock(\\Jiminny\\Models\\Lead::class);\n $entity->expects($this->once())->method('delete');\n\n $serviceMock->expects($this->once())\n ->method('dispatchDeleteCrmObjectJob')\n ->with($entity);\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('deleteCrmObject');\n $method->setAccessible(true);\n\n $method->invoke($serviceMock, $entity);\n }\n\n public function testDeleteCrmObjectWithNullEntity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['dispatchDeleteCrmObjectJob'])\n ->getMock();\n\n $serviceMock->expects($this->never())\n ->method('dispatchDeleteCrmObjectJob');\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('deleteCrmObject');\n $method->setAccessible(true);\n\n $method->invoke($serviceMock, null);\n }\n\n public function testDispatchDeleteCrmObjectJobWithNullEntity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($service);\n $method = $reflection->getMethod('dispatchDeleteCrmObjectJob');\n $method->setAccessible(true);\n\n // Should return early without dispatching - no exception expected\n $method->invoke($service, null);\n\n $this->assertTrue(true); // Test completed successfully\n }\n\n public function testDispatchDeleteCrmObjectJobWithUnsupportedEntity(): void\n {\n $this->expectException(\\TypeError::class);\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n $unsupportedEntity = $this->createMock(\\stdClass::class);\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($service);\n $method = $reflection->getMethod('dispatchDeleteCrmObjectJob');\n\n // This will throw TypeError due to union type constraint\n $method->invoke($service, $unsupportedEntity);\n }\n\n public function testHandleEntityDeletionByProviderIdMethodExists(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n // Test that the method exists and is accessible via reflection\n $reflection = new \\ReflectionClass($service);\n $method = $reflection->getMethod('handleEntityDeletionByProviderId');\n $method->setAccessible(true);\n\n // Verify method exists and has correct parameters\n $this->assertTrue($method->isProtected());\n $this->assertEquals(2, $method->getNumberOfParameters());\n\n $parameters = $method->getParameters();\n $this->assertEquals('targetEntity', $parameters[0]->getName());\n $this->assertEquals('crmData', $parameters[1]->getName());\n }\n\n public function testSyncRemotelyDeletedObjectsWithNoResults(): void\n {\n // Create a real service instance to avoid mock property issues\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n // Mock queryHandler to throw NoResultsException\n $queryHandler = $this->createMock(QueryHandler::class);\n $queryHandler->expects($this->once())\n ->method('queryDeleted')\n ->with('Opportunity')\n ->willThrowException(new NoResultsException('No results'));\n\n // Set the queryHandler using reflection on the real service\n $reflection = new \\ReflectionClass($service);\n $queryHandlerProperty = $reflection->getProperty('queryHandler');\n $queryHandlerProperty->setAccessible(true);\n $queryHandlerProperty->setValue($service, $queryHandler);\n\n $result = self::invokePrivateMethod('syncRemotelyDeletedObjects', $service, [CrmObject::OPPORTUNITY]);\n\n $this->assertFalse($result);\n }\n\n public function testSyncRemotelyDeletedObjectsWithEmptyResults(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n // Mock queryHandler to return empty results\n $queryResult = $this->createMock(QueryResults::class);\n $queryResult->method('getResults')->willReturn([]);\n\n $queryHandler = $this->createMock(QueryHandler::class);\n $queryHandler->expects($this->once())\n ->method('queryDeleted')\n ->with('Opportunity')\n ->willReturn($queryResult);\n\n // Set the queryHandler using reflection on the real service\n $reflection = new \\ReflectionClass($service);\n $queryHandlerProperty = $reflection->getProperty('queryHandler');\n $queryHandlerProperty->setAccessible(true);\n $queryHandlerProperty->setValue($service, $queryHandler);\n\n $result = self::invokePrivateMethod('syncRemotelyDeletedObjects', $service, [CrmObject::OPPORTUNITY]);\n\n $this->assertFalse($result);\n }\n\n public function testSyncRemotelyDeletedObjectsWithUnsupportedCrmObject(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n // Mock queryHandler to return some deleted objects so we reach the match statement\n $deletedObjects = [\n ['id' => 'task1'],\n ['id' => 'task2'],\n ];\n $queryResult = $this->createMock(QueryResults::class);\n $queryResult->method('getResults')->willReturn($deletedObjects);\n\n $queryHandler = $this->createMock(QueryHandler::class);\n $queryHandler->expects($this->once())\n ->method('queryDeleted')\n ->with('Task') // ucfirst('task') = 'Task'\n ->willReturn($queryResult);\n\n self::setPrivateProperty($service, 'queryHandler', $queryHandler);\n\n $this->expectException(InvalidArgumentException::class);\n $this->expectExceptionMessage('Unsupported CrmObject: task');\n\n self::invokePrivateMethod('syncRemotelyDeletedObjects', $service, [CrmObject::TASK]);\n }\n\n public static function crmObjectProvider(): array\n {\n return [\n 'Account' => [CrmObject::ACCOUNT],\n 'Contact' => [CrmObject::CONTACT],\n 'Lead' => [CrmObject::LEAD],\n 'Opportunity' => [CrmObject::OPPORTUNITY],\n ];\n }\n\n public function testVerifyTaskExistsReturnsTrue(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-123', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-123');\n $activity->method('getId')->willReturn(456);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-123', ['Id', 'IsDeleted'])\n ->willReturn(['Id' => 'task-123', 'IsDeleted' => false]);\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertTrue($result);\n }\n\n public function testVerifyTaskExistsReturnsTrueForEvent(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:event-123', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('event-123');\n $activity->method('getId')->willReturn(456);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_EVENT);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Event', 'event-123', ['Id', 'IsDeleted'])\n ->willReturn(['Id' => 'event-123', 'IsDeleted' => false]);\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertTrue($result);\n }\n\n public function testVerifyTaskExistsReturnsFalseWhenDeleted(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-456', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-456');\n $activity->method('getId')->willReturn(789);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-456', ['Id', 'IsDeleted'])\n ->willReturn(['Id' => 'task-456', 'IsDeleted' => true]);\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertFalse($result);\n }\n\n public function testVerifyTaskExistsReturnsFalseWhenNotFound(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-999', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-999');\n $activity->method('getId')->willReturn(999);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-999', ['Id', 'IsDeleted'])\n ->willThrowException(new \\Jiminny\\Exceptions\\HttpNotFoundException('Task not found'));\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertFalse($result);\n }\n\n public function testVerifyTaskExistsReturnsFalseWhenNoPlaybook(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-no-playbook', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-no-playbook');\n $activity->method('getId')->willReturn(111);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn(null);\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertFalse($result);\n }\n\n public function testVerifyTaskExistsThrowsExceptionForTransientErrors(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-error', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-error');\n $activity->method('getId')->willReturn(888);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-error', ['Id', 'IsDeleted'])\n ->willThrowException(new \\RuntimeException('Network timeout'));\n\n $this->expectException(\\RuntimeException::class);\n $this->expectExceptionMessage('Network timeout');\n\n $service->verifyTaskExists($activity);\n }\n\n public function testVerifyTaskExistsCachesResults(): void\n {\n $cachedValue = null;\n Cache::shouldReceive('remember')\n ->twice()\n ->with('crm_task_exists:123:task-cached', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(function ($key, $ttl, $callback) use (&$cachedValue) {\n if ($cachedValue === null) {\n $cachedValue = $callback();\n }\n\n return $cachedValue;\n });\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-cached');\n $activity->method('getId')->willReturn(555);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-cached', ['Id', 'IsDeleted'])\n ->willReturn(['Id' => 'task-cached', 'IsDeleted' => false]);\n\n $result1 = $service->verifyTaskExists($activity);\n $result2 = $service->verifyTaskExists($activity);\n\n $this->assertTrue($result1);\n $this->assertTrue($result2);\n }\n\n public function testVerifyTaskExistsReturnsFalseForHttpBadRequestException(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-400', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-400');\n $activity->method('getId')->willReturn(400);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-400', ['Id', 'IsDeleted'])\n ->willThrowException(new \\Jiminny\\Exceptions\\HttpBadRequestException('Bad request'));\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertFalse($result);\n }\n\n public function testImportOpportunitySkipsWhenNoProfileAndNoAccount(): void\n {\n $crmData = [\n 'Id' => 'SF-NO-USER-1',\n 'Name' => 'Test Opportunity',\n 'OwnerId' => 'owner-no-profile',\n // No AccountId\n ];\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(\\Illuminate\\Events\\Dispatcher::class); // ← ADD THIS\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n\n $service = new Service(\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService\n );\n\n $config = $this->createMock(Configuration::class);\n\n // Mock profiles relation returning null (no profile found)\n $profilesRelation = \\Mockery::mock(\\Illuminate\\Database\\Eloquent\\Relations\\HasMany::class);\n $profilesRelation->shouldReceive('where')->with('crm_provider_id', 'owner-no-profile')->andReturnSelf();\n $profilesRelation->shouldReceive('first')->andReturn(null);\n\n $config->expects($this->once())\n ->method('profiles')\n ->willReturn($profilesRelation);\n\n $team = $this->createMock(Team::class);\n $team->method('getId')->willReturn(1);\n\n $logger = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n $logger->expects($this->once())\n ->method('error')\n ->with(\n '[Salesforce] | Skip import, no user_id found',\n ['id' => 'SF-NO-USER-1']\n );\n\n $reflection = new \\ReflectionClass($service);\n\n $configProperty = $reflection->getProperty('config');\n $configProperty->setAccessible(true);\n $configProperty->setValue($service, $config);\n\n $teamProperty = $reflection->getProperty('team');\n $teamProperty->setAccessible(true);\n $teamProperty->setValue($service, $team);\n\n $loggerProperty = $reflection->getProperty('logger');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($service, $logger);\n\n // Initialize profile property to avoid \"must not be accessed before initialization\" error\n $profileProperty = $reflection->getProperty('profile');\n $profileProperty->setAccessible(true);\n $profileProperty->setValue($service, null);\n\n $result = self::invokePrivateMethod('importOpportunity', $service, [$crmData]);\n\n $this->assertNull($result);\n }\n\n public function testImportOpportunitySkipsWhenProfileNotFound(): void\n {\n $crmData = [\n 'Id' => 'SF-NO-USER-2',\n 'Name' => 'Test Opportunity',\n 'OwnerId' => 'owner-not-found',\n // No AccountId - avoid complex account processing\n ];\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $eventDispatcher = $this->createMock(\\Illuminate\\Events\\Dispatcher::class); // ← ADD THIS\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n\n\n $service = new Service(\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService\n );\n\n $config = $this->createMock(Configuration::class);\n\n // Mock profiles relation returning null (no profile found)\n $profilesRelation = \\Mockery::mock(\\Illuminate\\Database\\Eloquent\\Relations\\HasMany::class);\n $profilesRelation->shouldReceive('where')->with('crm_provider_id', 'owner-not-found')->andReturnSelf();\n $profilesRelation->shouldReceive('first')->andReturn(null);\n\n $config->expects($this->once())\n ->method('profiles')\n ->willReturn($profilesRelation);\n\n $team = $this->createMock(Team::class);\n $team->method('getId')->willReturn(1);\n\n $logger = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n $logger->expects($this->once())\n ->method('error')\n ->with(\n '[Salesforce] | Skip import, no user_id found',\n ['id' => 'SF-NO-USER-2']\n );\n\n $reflection = new \\ReflectionClass($service);\n\n $configProperty = $reflection->getProperty('config');\n $configProperty->setAccessible(true);\n $configProperty->setValue($service, $config);\n\n $teamProperty = $reflection->getProperty('team');\n $teamProperty->setAccessible(true);\n $teamProperty->setValue($service, $team);\n\n $loggerProperty = $reflection->getProperty('logger');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($service, $logger);\n\n // Initialize profile property\n $profileProperty = $reflection->getProperty('profile');\n $profileProperty->setAccessible(true);\n $profileProperty->setValue($service, null);\n\n $result = self::invokePrivateMethod('importOpportunity', $service, [$crmData]);\n\n $this->assertNull($result);\n }\n}\n\nclass HasManyExtended extends HasMany\n{\n public function where()\n {\n }\n\n public function first()\n {\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"A","depth":4,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":5,"bounds":{"left":0.0,"top":0.0,"width":0.008333334,"height":0.03},"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Select All","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"320","depth":6,"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"320","depth":7,"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"5772","depth":8,"bounds":{"left":0.0,"top":0.0,"width":0.2048611,"height":0.024444444},"on_screen":false,"value":"5772","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"46ea38f7-c940-395d-9c79-0329d30e9ab7","depth":6,"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"46ea38f7-c940-395d-9c79-0329d30e9ab7","depth":7,"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"2024-11-26 09:21:15","depth":8,"bounds":{"left":0.0,"top":0.0,"width":0.2048611,"height":0.024444444},"on_screen":false,"value":"2024-11-26 09:21:15","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"401","depth":6,"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"401","depth":7,"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"5772","depth":8,"bounds":{"left":0.0,"top":0.0,"width":0.2048611,"height":0.024444444},"on_screen":false,"value":"5772","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"<null>","depth":6,"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"<null>","depth":7,"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"5772","depth":8,"bounds":{"left":0.0,"top":0.0,"width":0.2048611,"height":0.024444444},"on_screen":false,"value":"5772","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"salesforce","depth":6,"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"salesforce","depth":7,"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"2024-11-26 09:21:15","depth":8,"bounds":{"left":0.0,"top":0.0,"width":0.2048611,"height":0.024444444},"on_screen":false,"value":"2024-11-26 09:21:15","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"enterprise","depth":6,"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"enterprise","depth":7,"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"2024-11-26 09:21:15","depth":8,"bounds":{"left":0.0,"top":0.0,"width":0.2048611,"height":0.024444444},"on_screen":false,"value":"2024-11-26 09:21:15","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"SWE38","depth":6,"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"SWE38","depth":7,"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"2024-11-26 09:21:15","depth":8,"bounds":{"left":0.0,"top":0.0,"width":0.2048611,"height":0.024444444},"on_screen":false,"value":"2024-11-26 09:21:15","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"0","depth":6,"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"0","depth":7,"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"5772","depth":8,"bounds":{"left":0.0,"top":0.0,"width":0.2048611,"height":0.024444444},"on_screen":false,"value":"5772","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"50.0","depth":6,"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"50.0","depth":7,"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"2024-11-26 09:21:15","depth":8,"bounds":{"left":0.0,"top":0.0,"width":0.2048611,"height":0.024444444},"on_screen":false,"value":"2024-11-26 09:21:15","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
7732332105235147404
|
-6520523286572803295
|
idle
|
accessibility
|
NULL
|
Project: faVsco.js, menu
JY-20963-fix-import-on-de Project: faVsco.js, menu
JY-20963-fix-import-on-deleted-entity, menu
Start Listening for PHP Debug Connections
ServiceTest
Rerun 'PHPUnit: ServiceTest'
Debug 'ServiceTest'
Stop 'ServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Built-in Preview
Chrome
Firefox
Safari
Sync Changes
Hide This Notification
Code changed:
Hide
4
32
168
1
28
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Tests\Unit\Services\Crm\Salesforce;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Events\Dispatcher;
use Illuminate\Support\Facades\Cache;
use Jiminny\Component\Country\CountriesMap;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Enums\CrmObject;
use Jiminny\Exceptions\InvalidArgumentException;
use Jiminny\Exceptions\NoResultsException;
use Jiminny\Integrations\PlaybookResolver;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Playbook;
use Jiminny\Models\Team;
use Jiminny\Models\TeamSettings;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\ContactRoleRepository;
use Jiminny\Repositories\Crm\FieldDataRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\Salesforce\Client;
use Jiminny\Services\Crm\Salesforce\PayloadBuilder;
use Jiminny\Services\Crm\Salesforce\QueryBuilder;
use Jiminny\Services\Crm\Salesforce\QueryHandler;
use Jiminny\Services\Crm\Salesforce\QueryIterator;
use Jiminny\Services\Crm\Salesforce\QueryResults;
use Jiminny\Services\Crm\Salesforce\Service;
use PHPUnit\Framework\MockObject\MockObject;
use Tests\TestCase;
use Tests\Unit\Traits\TestPrivateMethod;
class ServiceTest extends TestCase
{
use TestPrivateMethod;
public function testFetchAndAssociateRelatedActivity(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$payloadBuilder->method('addCustomLogicFieldsPayload')
->willReturnCallback(function ($activity, $payload) {
return $payload;
});
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods(['fetchRelatedActivity', 'getPlaybook', 'getPlaybookCategory', 'updateRecord'])
->getMock();
$serviceMock->expects($this->once())
->method('fetchRelatedActivity')
->willReturn([
'Id' => 'testId',
'Type' => null,
'OwnerId' => 'testerUser',
'Description' => 'Test description',
]);
$user = $this->createMock(User::class);
$team = $this->createMock(Team::class);
$user->method('getAttribute')->with('team')->willReturn($team);
$playbook = $this->createMock(Playbook::class);
$playbook->method('getActivityField')->willReturn(null);
$playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_EVENT);
$serviceMock->expects($this->once())
->method('getPlaybook')
->with($user)
->willReturn($playbook);
$serviceMock->expects($this->never())
->method('getPlaybookCategory');
$serviceMock->expects($this->never())
->method('updateRecord');
$fieldDataRepository = $this->createMock(FieldDataRepository::class);
$fieldDataRepository->method('getActivityFieldData')->willReturn(collect([]));
app()->instance(FieldDataRepository::class, $fieldDataRepository);
$config = $this->createMock(Configuration::class);
$profilesRelation = $this->getMockBuilder(\Illuminate\Database\Eloquent\Relations\HasMany::class)
->disableOriginalConstructor()
->onlyMethods(['get'])
->addMethods(['where'])
->getMock();
$profilesRelation->method('where')->willReturnSelf();
$profilesRelation->method('get')->willReturn(collect([]));
$config->method('profiles')->willReturn($profilesRelation);
$serviceMock->config = $config;
$serviceMock->profile = null;
$actualStartTime = \Carbon\Carbon::now();
$activity = $this->getMockBuilder(Activity::class)
->disableOriginalConstructor()
->onlyMethods(['update', 'hasProspect'])
->getMock();
$activity->method('update')->willReturn(true);
$activity->method('hasProspect')->willReturn(true);
$activity->type = Activity::TYPE_CONFERENCE;
$activity->provider = Activity::PROVIDER_TWILIO;
$activity->lead_id = 1;
$activity->user_id = 0;
$activity->id_string = 'test-activity-id';
$activity->user = $user;
$activity->actual_start_time = $actualStartTime;
$activity->uuid = 'c53d8320-f556-4cee-a2f8-5f232f454ca4';
app()->bind(PlaybookResolver::class, function () use ($user) {
$playbook = $this->createMock(Playbook::class);
$playbookResolver = $this->createMock(PlaybookResolver::class);
$playbookResolver->expects($this->once())
->method('resolvePlaybookByUser')
->with($user)
->willReturn($playbook);
return $playbookResolver;
});
$data = $serviceMock->fetchAndAssociateRelatedActivity($activity);
$this->assertInstanceOf(Activity::class, $data);
$this->assertEquals(Activity::TYPE_CONFERENCE, $data->getType());
$this->assertEquals($actualStartTime->getTimestamp(), $data->getActualStartTime()->getTimestamp());
}
public function testFetchAndAssociateRelatedActivitySkipsForTaskBasedPlaybook(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods(['fetchRelatedActivity', 'getPlaybook'])
->getMock();
$user = $this->createMock(User::class);
$playbook = $this->createMock(Playbook::class);
$playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);
$playbook->method('getId')->willReturn(123);
$serviceMock->expects($this->once())
->method('getPlaybook')
->with($user)
->willReturn($playbook);
$serviceMock->expects($this->never())
->method('fetchRelatedActivity');
$activity = $this->getMockBuilder(Activity::class)
->disableOriginalConstructor()
->onlyMethods(['hasProspect', 'getUuid'])
->getMock();
$activity->method('hasProspect')->willReturn(true);
$activity->method('getUuid')->willReturn('c53d8320-f556-4cee-a2f8-5f232f454ca4');
$activity->type = Activity::TYPE_CONFERENCE;
$activity->actual_start_time = \Carbon\Carbon::now();
$activity->user = $user;
$result = $serviceMock->fetchAndAssociateRelatedActivity($activity);
$this->assertNull($result);
}
public function testFetchAndAssociateRelatedActivityReturnsNullForNonConference(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$serviceMock = new Service(
client: $client,
payloadBuilder: $payloadBuilder,
eventDispatcher: $eventDispatcher,
countriesMap: $countriesMap,
prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)
);
$activity = $this->getMockBuilder(Activity::class)
->disableOriginalConstructor()
->getMock();
$activity->type = Activity::TYPE_SOFTPHONE;
$result = $serviceMock->fetchAndAssociateRelatedActivity($activity);
$this->assertNull($result);
}
public function testFetchAndAssociateRelatedActivityReturnsNullWhenNoStartTime(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$serviceMock = new Service(
client: $client,
payloadBuilder: $payloadBuilder,
eventDispatcher: $eventDispatcher,
countriesMap: $countriesMap,
prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)
);
$activity = $this->getMockBuilder(Activity::class)
->disableOriginalConstructor()
->getMock();
$activity->type = Activity::TYPE_CONFERENCE;
$activity->actual_start_time = null;
$activity->scheduled_start_time = null;
$result = $serviceMock->fetchAndAssociateRelatedActivity($activity);
$this->assertNull($result);
}
public function testFetchAndAssociateRelatedActivityReturnsNullWhenNoProspect(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods(['getPlaybook'])
->getMock();
$serviceMock->expects($this->never())
->method('getPlaybook');
$activity = $this->getMockBuilder(Activity::class)
->disableOriginalConstructor()
->onlyMethods(['hasProspect', 'getUuid'])
->getMock();
$activity->method('hasProspect')->willReturn(false);
$activity->method('getUuid')->willReturn('c53d8320-f556-4cee-a2f8-5f232f454ca4');
$activity->type = Activity::TYPE_CONFERENCE;
$activity->actual_start_time = \Carbon\Carbon::now();
$result = $serviceMock->fetchAndAssociateRelatedActivity($activity);
$this->assertNull($result);
}
public function testMatchExactlyByEmail(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods([])
->getMock();
$profile = new Profile();
$profile->setAttribute('id', bin2hex(random_bytes(8)));
$serviceMock->profile = $profile;
$team = $this->createMock(Team::class);
$serviceMock->team = $team;
$data = $serviceMock->matchExactlyByEmail(bin2hex(random_bytes(8)) . '[EMAIL]');
$this->assertEquals(null, $data);
}
public function testMatchDomainFromEmail(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$queryIterator = $this->createMock(QueryIterator::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$config = $this->createMock(Configuration::class);
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('search')
->willReturn($queryIterator);
return $handler;
});
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods(['convertCrmData'])
->getMock();
$profile = new Profile();
$profile->account_fields = 'Field1, Field2, Field3';
$serviceMock->profile = $profile;
$serviceMock->expects($this->once())
->method('convertCrmData')
->willReturn(['test']);
$this->app->bind(QueryBuilder::class, function () {
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->expects($this->once())
->method('buildMatchByDomainQuery')
->with('[EMAIL]')
->willReturn('FIND {[EMAIL]} IN ALL FIELDS RETURNING Account(Id)');
return $queryBuilder;
});
$team = $this->createMock(Team::class);
$serviceMock->team = $team;
$serviceMock->config = $config;
$data = $serviceMock->matchByDomain('[EMAIL]');
$this->assertEquals(['test'], $data);
}
public function testBuildTaskSearchFields(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$service = new Service(
client: $client,
payloadBuilder: $payloadBuilder,
eventDispatcher: $eventDispatcher,
countriesMap: $countriesMap,
prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)
);
$fields = $service->buildTaskSearchFields();
$expectedFields = ['Id', 'WhoId', 'WhatId', 'AccountId'];
$this->assertEquals($expectedFields, $fields);
}
public function testMapCrmObjects(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$service = new Service(
client: $client,
payloadBuilder: $payloadBuilder,
eventDispatcher: $eventDispatcher,
countriesMap: $countriesMap,
prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class),
);
$sampleTask = [
'WhoId' => '003sampleWhoId',
'AccountId' => 'sampleAccountId',
'WhatId' => 'sampleWhatId',
];
$activityData = $service->mapCrmObjects($sampleTask);
$expectedActivityData = [
'contact' => '003sampleWhoId',
'account' => 'sampleAccountId',
'opportunity' => 'sampleWhatId',
];
$this->assertEquals($expectedActivityData, $activityData);
}
public function testGetInstalledAppVersion(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$queryIterator = $this->createMock(QueryIterator::class);
$queryIterator->expects($this->any())
->method('current')->willReturn([
'SubscriberPackageVersion' => [
'MajorVersion' => '1',
'MinorVersion' => '0',
'PatchVersion' => '1',
'BuildNumber' => '0',
],
]);
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('metadata')
->willReturn($queryIterator);
return $handler;
});
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods(array_diff(get_class_methods(Service::class), ['getInstalledAppVersion']))
->getMock();
$version = $serviceMock->getInstalledAppVersion();
$this->assertEquals('1010', $version);
}
public function testSyncProfiles(): void
{
$userToSearch = null;
$team = $this->createMock(Team::class);
$config = $this->createMockedConfiguration();
$config->expects($this->once())
->method('getId')
->willReturn(1);
$salesforceUser = [
'Email' => '[EMAIL]',
'UserPreferencesLightningExperiencePreferred' => true,
'CallCenterId' => '123',
'Id' => '456',
'ProfileId' => '789',
];
app()->bind(QueryBuilder::class, function () use ($userToSearch) {
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->expects($this->once())
->method('buildGetUsersQuery')
->with($userToSearch)
->willReturn('SELECT * FROM Users');
return $queryBuilder;
});
$queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('query')
->with('SELECT * FROM Users')
->willReturn($queryIterator);
return $handler;
});
$user = $this->createMock(User::class);
$user->expects($this->once())
->method('getId')
->willReturn(123);
$this->mockTeamRepository($team, $salesforceUser, $user);
$profileRepository = $this->createMock(ProfileRepository::class);
$profileRepository->expects($this->once())
->method('updateOrCreateProfile')
->with(
$user,
[
'crm_configuration_id' => 1,
'crm_provider_id' => '456',
],
[
'user_id' => 123,
'edition' => Profile::EDITION_LIGHTNING,
'has_external_cti' => true,
'crm_profile_id' => '789',
]
)
->willReturn(new Profile());
$this->app->instance(ProfileRepository::class, $profileRepository);
$serviceMock = $this->getServiceMock();
$serviceMock->team = $team;
$serviceMock->config = $config;
$result = $serviceMock->syncProfiles($userToSearch);
$this->assertNull($result);
}
public function testSyncProfilesEmailIsNull(): void
{
$userToSearch = $this->createMock(User::class);
$salesforceUser = [
'Email' => null,
];
app()->bind(QueryBuilder::class, function () {
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->expects($this->once())
->method('buildGetUsersQuery')
->with(null)
->willReturn('SELECT * FROM Users');
return $queryBuilder;
});
$queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->exactly(2))
->method('query')
->willReturn($queryIterator);
return $handler;
});
$team = $this->createMock(Team::class);
$user = $this->createMock(User::class);
$this->mockTeamRepository($team, $salesforceUser, $user, false);
$config = $this->createMock(Configuration::class);
$serviceMock = $this->getServiceMock();
$serviceMock->team = $team;
$serviceMock->config = $config;
$profile = $serviceMock->syncProfiles(null);
$this->assertNull($profile);
}
public function testSyncProfilesUserToSearchMatchesCurrentUser(): void
{
$userToSearch = $this->createMock(User::class);
$userToSearch->expects($this->once())
->method('getId')
->willReturn(123);
$team = $this->createMock(Team::class);
$config = $this->createMock(Configuration::class);
$config->expects($this->once())
->method('getId')
->willReturn(1);
$salesforceUser = [
'Email' => '[EMAIL]',
'UserPreferencesLightningExperiencePreferred' => true,
'CallCenterId' => '123',
'Id' => '456',
'ProfileId' => '789',
];
app()->bind(QueryBuilder::class, function () use ($userToSearch) {
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->expects($this->once())
->method('buildGetUsersQuery')
->with($userToSearch)
->willReturn('SELECT * FROM Users');
return $queryBuilder;
});
$queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('query')
->with('SELECT * FROM Users')
->willReturn($queryIterator);
return $handler;
});
$user = $this->createMock(User::class);
$user->expects($this->exactly(2))
->method('getId')
->willReturn(123);
$this->mockTeamRepository($team, $salesforceUser, $user);
$profileRepository = $this->createMock(ProfileRepository::class);
$profileRepository->expects($this->once())
->method('updateOrCreateProfile')
->with(
$user,
[
'crm_configuration_id' => 1,
'crm_provider_id' => '456',
],
[
'user_id' => 123,
'edition' => Profile::EDITION_LIGHTNING,
'has_external_cti' => true,
'crm_profile_id' => '789',
]
)
->willReturn(new Profile());
$this->app->instance(ProfileRepository::class, $profileRepository);
$serviceMock = $this->getServiceMock();
$serviceMock->team = $team;
$serviceMock->config = $config;
$profile = $serviceMock->syncProfiles($userToSearch);
$this->assertInstanceOf(Profile::class, $profile);
}
public function testSyncProfilesWithCustomValidation(): void
{
$userToSearch = null;
$team = $this->createMock(Team::class);
$config = $this->createMockedConfiguration();
$config->expects($this->atLeastOnce()) // Changed from once() to atLeastOnce()
->method('getId')
->willReturn(1);
$salesforceUser = [
'Email' => '[EMAIL]',
'UserPreferencesLightningExperiencePreferred' => true,
'CallCenterId' => '123',
'Id' => '456',
'ProfileId' => '789',
'CustomField' => 'CustomValue',
];
$customRules = [
['field' => 'CustomField', 'value' => 'CustomValue'],
];
$this->mockQueryBuilderAndHandler($userToSearch, [$salesforceUser]);
$user = $this->createMock(User::class);
$user->expects($this->once())
->method('getId')
->willReturn(123);
$this->mockTeamRepository($team, $salesforceUser, $user, true, $customRules);
$this->mockProfileRepository($user);
$serviceMock = $this->getServiceMock();
$serviceMock->team = $team;
$serviceMock->config = $config;
$profile = $serviceMock->syncProfiles($userToSearch);
$this->assertNull($profile);
}
public function testSyncProfilesWithCustomValidationFailing(): void
{
$userToSearch = null;
$team = $this->createMock(Team::class);
$config = $this->createMockedConfiguration();
$salesforceUser = [
'Email' => '[EMAIL]',
'UserPreferencesLightningExperiencePreferred' => true,
'CallCenterId' => '123',
'Id' => '456',
'ProfileId' => '789',
'CustomField' => 'WrongValue',
];
$customRules = [
['field' => 'CustomField', 'value' => 'CustomValue'],
];
$this->mockQueryBuilderAndHandler($userToSearch, [$salesforceUser]);
$teamRepository = $this->getMockForAbstractClass(TeamRepository::class, [], '', false, true, true, ['findActiveTeamMemberByEmail', 'getTeamSetting']);
$teamSettings = $this->createMock(TeamSettings::class);
$teamSettings->method('getValueType')
->willReturn('array');
$teamSettings->method('getValue')
->willReturn(json_encode($customRules));
$teamRepository->expects($this->once())
->method('getTeamSetting')
->with($team, 'custom_profile_validation')
->willReturn($teamSettings);
app()->bind(TeamRepository::class, function () use ($teamRepository) {
return $teamRepository;
});
$profileRepository = $this->createMock(ProfileRepository::class);
$profileRepository->expects($this->never())
->method('updateOrCreateProfile');
$this->app->instance(ProfileRepository::class, $profileRepository);
$serviceMock = $this->getServiceMock();
$serviceMock->team = $team;
$serviceMock->config = $config;
$result = $serviceMock->syncProfiles($userToSearch);
$this->assertNull($result);
}
public function testGetContactRolesFromCrm(): void
{
$contactRoles = [
[
'Id' => '1',
'ContactId' => 'Contact1',
'OpportunityId' => 'Opportunity1',
'Opportunity' => ['OwnerId' => 'Owner1'],
'IsPrimary' => true,
'Role' => 'Decision Maker',
],
];
$expectedResponse = [
[
'id' => '1',
'contactId' => 'Contact1',
'opportunityId' => 'Opportunity1',
'ownerId' => 'Owner1',
'isPrimary' => true,
'role' => 'Decision Maker',
],
];
$this->bindQueryIterator($contactRoles);
$serviceMock = $this->getServiceMock();
$data = $serviceMock->getContactRolesFromCrm(now()->subDay());
$this->assertEquals($expectedResponse, $data);
}
public function testGetContactRolesFromCrmNoResult(): void
{
$this->bindQueryIterator([]);
$serviceMock = $this->getServiceMock();
$data = $serviceMock->getContactRolesFromCrm(now()->subDay());
$this->assertEquals([], $data);
}
public function testSyncContactRoles(): void
{
$contactRoles = [
[
'id' => '1',
'contactId' => 'Contact1',
'opportunityId' => 'Opportunity1',
'ownerId' => 'Owner1',
'isPrimary' => true,
'role' => 'Decision Maker',
],
];
app()->bind(ContactRoleRepository::class, function () {
$contactRoleRepository = $this->createMock(ContactRoleRepository::class);
$contactRoleRepository->expects($this->once())
->method('saveContactRoles');
return $contactRoleRepository;
});
$serviceMock = $this->getServiceMock([
'getContactRolesFromCrm',
'syncRemotelyDeletedContactRoles',
'syncContact',
'syncOpportunity',
]);
$config = $this->createMock(Configuration::class);
$hasMany = $this->createMock(HasManyExtended::class);
$hasMany->expects($this->exactly(2))
->method('where')
->willReturn($hasMany);
$hasMany->expects($this->exactly(2))
->method('first')
->willReturn(
$this->createMock(Contact::class),
$this->createMock(Opportunity::class)
);
$config->expects($this->once())
->method('contacts')
->willReturn($hasMany);
$config->expects($this->once())
->method('opportunities')
->willReturn($hasMany);
$serviceMock->config = $config;
$serviceMock->expects($this->once())
->method('getContactRolesFromCrm')
->willReturn($contactRoles);
$serviceMock->expects($this->once())
->method('syncRemotelyDeletedContactRoles');
$data = $serviceMock->syncContactRoles(now()->subDay());
$this->assertEquals(1, $data);
}
public function testSyncRemotelyDeletedContactRoles(): void
{
$contactRoles = [
[
'id' => '1',
'crm_provider_id' => '1',
],
];
app()->bind(QueryHandler::class, function () use ($contactRoles) {
$queryResults = new QueryResults($contactRoles, 1, true, null);
$handler = $this->createMock(QueryHandler::class);
$handler->method('queryDeleted')
->willReturn($queryResults);
return $handler;
});
app()->bind(ContactRoleRepository::class, function () {
$contactRoleRepository = $this->createMock(ContactRoleRepository::class);
$contactRoleRepository->expects($this->once())
->method('deleteContactRoles');
return $contactRoleRepository;
});
$serviceMock = $this->getServiceMock();
$serviceMock->team = $this->createMock(Team::class);
$data = $this->invokePrivateMethod('syncRemotelyDeletedContactRoles', $serviceMock, []);
$this->assertTrue($data);
}
private function bindQueryIterator(array $queryResult): void
{
/** @var Client $client */
$client = $this->createMock(Client::class);
$queryIterator = new QueryIterator(
$client,
new QueryResults($queryResult, 1, true, null)
);
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('query')
->willReturn($queryIterator);
return $handler;
});
}
public static function getOpportunitySortOrderDataProvider(): array
{
return [
'all open recently updated' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED, ['LastModifiedDate DESC', true]],
'all open recently created' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED, ['CreatedDate DESC', true]],
'all open oldest created' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED, ['CreatedDate ASC', true]],
'all recently updated' => [Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED, ['LastModifiedDate DESC', false]],
'default' => ['unknown', ['LastModifiedDate DESC', true]],
];
}
private function createMockedConfiguration(): Configuration
{
$config = $this->createMock(Configuration::class);
$profilesRelation = $this->getMockBuilder(\Illuminate\Database\Eloquent\Relations\HasMany::class)
->disableOriginalConstructor()
->onlyMethods(['get'])
->addMethods(['where', 'first'])
->getMock();
$profilesRelation->method('where')->willReturnSelf();
$profilesRelation->method('get')->willReturn(collect([]));
$profilesRelation->method('first')->willReturn(null);
$config->method('profiles')->willReturn($profilesRelation);
return $config;
}
private function getServiceMock(array $onlyMethods = []): MockObject&Service
{
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$this->createMock(Client::class),
$this->createMock(PayloadBuilder::class),
$this->createMock(Dispatcher::class),
$this->createMock(CountriesMap::class),
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods($onlyMethods)
->getMock();
$serviceMock->profile = $this->createMock(Profile::class);
return $serviceMock;
}
private function mockQueryBuilderAndHandler($userToSearch, $salesforceUsers): void
{
app()->bind(QueryBuilder::class, function () use ($userToSearch) {
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->expects($this->once())
->method('buildGetUsersQuery')
->with($userToSearch)
->willReturn('SELECT * FROM Users');
return $queryBuilder;
});
$queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults($salesforceUsers, count($salesforceUsers), true, null));
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('query')
->willReturn($queryIterator);
return $handler;
});
}
private function mockTeamRepository(
Team $team,
array $salesforceUser,
?User $user = null,
bool $userSearch = true,
array $customRules = []
): void {
$teamRepository = $this->getMockForAbstractClass(TeamRepository::class, [], '', false, true, true, ['findActiveTeamMemberByEmail', 'getTeamSetting']);
if ($userSearch) {
$teamRepository->expects($this->once())
->method('findActiveTeamMemberByEmail')
->with($team, $salesforceUser['Email'])
->willReturn($user);
}
$teamSettings = $this->createMock(TeamSettings::class);
$teamSettings->method('getValueType')
->willReturn('array');
$teamSettings->method('getValue')
->willReturn(json_encode($customRules));
$teamRepository->expects($this->once())
->method('getTeamSetting')
->with($team, 'custom_profile_validation')
->willReturn($teamSettings);
app()->bind(TeamRepository::class, function () use ($teamRepository) {
return $teamRepository;
});
}
private function mockProfileRepository(User $user): void
{
$profileRepository = $this->createMock(ProfileRepository::class);
$profileRepository->expects($this->once())
->method('updateOrCreateProfile')
->with(
$user,
[
'crm_configuration_id' => 1,
'crm_provider_id' => '456',
],
[
'user_id' => 123,
'edition' => Profile::EDITION_LIGHTNING,
'has_external_cti' => true,
'crm_profile_id' => '789',
]
)
->willReturn(new Profile());
$this->app->instance(ProfileRepository::class, $profileRepository);
}
public function testBuildEnhancedNoteDecodesHtmlEntities(): void
{
$service = $this->getServiceMock(['createRecord']);
$profile = new Profile();
$profile->setAttribute('crm_provider_id', 'owner-123');
$service->profile = $profile;
$service->expects($this->exactly(2))
->method('createRecord')
->willReturnOnConsecutiveCalls('note-id-123', 'link-id-456');
$bodyWithEntities = 'Welch's current challenges and Facebook's Club';
$result = $this->invokePrivateMethod('buildEnhancedNote', $service, [
'Test Title',
$bodyWithEntities,
'object-id-789',
]);
$this->assertEquals('note-id-123', $result);
}
public function testBuildEnhancedNoteSanitizesWithoutQuotes(): void
{
$service = $this->getServiceMock(['createRecord']);
$profile = new Profile();
$profile->setAttribute('crm_provider_id', 'owner-456');
$service->profile = $profile;
$service->expects($this->exactly(2))
->method('createRecord')
->willReturnCallback(function ($type, $data) {
if ($type === 'ContentNote') {
$decoded = base64_decode($data['Content']);
$this->assertStringContainsString("Welch's", $decoded);
$this->assertStringNotContainsString(''', $decoded);
$this->assertStringNotContainsString('&#039;', $decoded);
$this->assertStringContainsString('<script>', $decoded);
return 'note-id-456';
}
return 'link-id-789';
});
$bodyWithMixedContent = "Welch's and <script>alert('xss')</script>";
$result = $this->invokePrivateMethod('buildEnhancedNote', $service, [
'Test Title',
$bodyWithMixedContent,
'object-id-123',
]);
$this->assertEquals('note-id-456', $result);
}
public function testBuildEnhancedNoteConvertsLineBreaks(): void
{
$service = $this->getServiceMock(['createRecord']);
$profile = new Profile();
$profile->setAttribute('crm_provider_id', 'owner-789');
$service->profile = $profile;
$service->expects($this->exactly(2))
->method('createRecord')
->willReturnCallback(function ($type, $data) {
if ($type === 'ContentNote') {
$decoded = base64_decode($data['Content']);
$this->assertStringContainsString('<br>', $decoded);
$this->assertStringNotContainsString('<br />', $decoded);
return 'note-id-789';
}
return 'link-id-012';
});
$bodyWithLineBreaks = "Line 1\nLine 2\nLine 3";
$result = $this->invokePrivateMethod('buildEnhancedNote', $service, [
'Test Title',
$bodyWithLineBreaks,
'object-id-456',
]);
$this->assertEquals('note-id-789', $result);
}
public function testBuildEnhancedNoteHandlesComplexScenario(): void
{
$service = $this->getServiceMock(['createRecord']);
$profile = new Profile();
$profile->setAttribute('crm_provider_id', 'owner-complex');
$service->profile = $profile;
$service->expects($this->exactly(2))
->method('createRecord')
->willReturnCallback(function ($type, $data) {
if ($type === 'ContentNote') {
$decoded = base64_decode($data['Content']);
$this->assertStringContainsString("Welch's", $decoded);
$this->assertStringContainsString("Facebook's Club", $decoded);
$this->assertStringContainsString("Arctics'", $decoded);
$this->assertStringNotContainsString(''', $decoded);
$this->assertStringNotContainsString('&#039;', $decoded);
$this->assertStringContainsString('<br>', $decoded);
$this->assertStringContainsString('<', $decoded);
$this->assertStringContainsString('>', $decoded);
return 'note-complex';
}
return 'link-complex';
});
$complexBody = "Summary:\n---------\nThe call focused on understanding Welch's current challenges and exploring how Arctics' Revenue Growth Management solutions could support their strategic goals.\n\n• John SMith discussed his role as a category advisor for Google and Facebook's Club, emphasizing the importance of market research and advising on product assortment.\n• Madona introduced Arctics' Virtual Shoppers AI, which simulates consumer <behavior> to optimize pricing and promotional strategies.";
$result = $this->invokePrivateMethod('buildEnhancedNote', $service, [
'Jiminny Transcription Summary',
$complexBody,
'task-id-001',
]);
$this->assertEquals('note-complex', $result);
}
public function testSyncRemotelyDeletedObjectsWithErrorHandlingSuccess(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$prospectPhotoPathService,
])
->onlyMethods(['syncRemotelyDeletedObjects'])
->getMock();
// Mock team
$team = $this->createMock(Team::class);
$team->method('getUuid')->willReturn('team-uuid-123');
$serviceMock->team = $team;
// Expect syncRemotelyDeletedObjects to be called once and succeed
$serviceMock->expects($this->once())
->method('syncRemotelyDeletedObjects')
->with(\Jiminny\Enums\CrmObject::ACCOUNT);
// Call the protected method using reflection
$reflection = new \ReflectionClass($serviceMock);
$method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');
$method->setAccessible(true);
// Should not throw any exceptions
$method->invoke($serviceMock, \Jiminny\Enums\CrmObject::ACCOUNT);
$this->assertTrue(true); // Test completed successfully
}
public function testSyncRemotelyDeletedObjectsWithErrorHandlingFailure(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$prospectPhotoPathService,
])
->onlyMethods(['syncRemotelyDeletedObjects'])
->getMock();
// Mock team
$team = $this->createMock(Team::class);
$team->method('getUuid')->willReturn('team-uuid-456');
$serviceMock->team = $team;
// Mock logger to verify warning is logged
$logger = $this->createMock(\Psr\Log\LoggerInterface::class);
// Use reflection to set the protected logger property
$reflection = new \ReflectionClass($serviceMock);
$loggerProperty = $reflection->getProperty('logger');
$loggerProperty->setAccessible(true);
$loggerProperty->setValue($serviceMock, $logger);
$exception = new \Exception('Sync failed due to API error');
// Expect syncRemotelyDeletedObjects to throw an exception
$serviceMock->expects($this->once())
->method('syncRemotelyDeletedObjects')
->with(\Jiminny\Enums\CrmObject::CONTACT)
->willThrowException($exception);
// Expect warning to be logged with correct message and parameters
$logger->expects($this->once())
->method('warning')
->with(
'[Salesforce] Remotely deleted objects sync failed',
[
'objectType' => 'contact',
'teamId' => 'team-uuid-456',
'reason' => 'Sync failed due to API error',
]
);
// Call the protected method using reflection
$reflection = new \ReflectionClass($serviceMock);
$method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');
$method->setAccessible(true);
// Should not re-throw the exception, just log it
$method->invoke($serviceMock, \Jiminny\Enums\CrmObject::CONTACT);
$this->assertTrue(true); // Test completed successfully
}
public function testSyncRemotelyDeletedObjectsWithErrorHandlingWithLogParams(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$prospectPhotoPathService,
])
->onlyMethods(['syncRemotelyDeletedObjects'])
->getMock();
// Mock team
$team = $this->createMock(Team::class);
$team->method('getUuid')->willReturn('team-uuid-789');
$serviceMock->team = $team;
// Mock logger to verify warning is logged
$logger = $this->createMock(\Psr\Log\LoggerInterface::class);
// Use reflection to set the protected logger property
$loggerReflection = new \ReflectionClass($serviceMock);
$loggerProperty = $loggerReflection->getProperty('logger');
$loggerProperty->setAccessible(true);
$loggerProperty->setValue($serviceMock, $logger);
$exception = new \Exception('Network timeout');
// Expect syncRemotelyDeletedObjects to throw an exception
$serviceMock->expects($this->once())
->method('syncRemotelyDeletedObjects')
->with(\Jiminny\Enums\CrmObject::OPPORTUNITY)
->willThrowException($exception);
// Additional log parameters
$logParams = [
'syncType' => 'full',
'batchSize' => 100,
];
// Expect warning to be logged with merged parameters
$logger->expects($this->once())
->method('warning')
->with(
'[Salesforce] Remotely deleted objects sync failed',
[
'objectType' => 'opportunity',
'teamId' => 'team-uuid-789',
'reason' => 'Network timeout',
'syncType' => 'full',
'batchSize' => 100,
]
);
// Call the protected method using reflection
$reflection = new \ReflectionClass($serviceMock);
$method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');
...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
70622
|
2537
|
4
|
2026-05-22T10:23:35.033925+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779445415033_m2.jpg...
|
PhpStorm
|
faVsco.js – ServiceTest.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20963-fix-import-on-de Project: faVsco.js, menu
JY-20963-fix-import-on-deleted-entity, menu
Start Listening for PHP Debug Connections
ServiceTest
Rerun 'PHPUnit: ServiceTest'
Debug 'ServiceTest'
Stop 'ServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Built-in Preview
Chrome
Firefox
Safari
Sync Changes
Hide This Notification
Code changed:
Hide
4
32
168
1
5
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Tests\Unit\Services\Crm\Salesforce;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Events\Dispatcher;
use Illuminate\Support\Facades\Cache;
use Jiminny\Component\Country\CountriesMap;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Enums\CrmObject;
use Jiminny\Exceptions\InvalidArgumentException;
use Jiminny\Exceptions\NoResultsException;
use Jiminny\Integrations\PlaybookResolver;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Playbook;
use Jiminny\Models\Team;
use Jiminny\Models\TeamSettings;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\ContactRoleRepository;
use Jiminny\Repositories\Crm\FieldDataRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\Salesforce\Client;
use Jiminny\Services\Crm\Salesforce\PayloadBuilder;
use Jiminny\Services\Crm\Salesforce\QueryBuilder;
use Jiminny\Services\Crm\Salesforce\QueryHandler;
use Jiminny\Services\Crm\Salesforce\QueryIterator;
use Jiminny\Services\Crm\Salesforce\QueryResults;
use Jiminny\Services\Crm\Salesforce\Service;
use PHPUnit\Framework\MockObject\MockObject;
use Tests\TestCase;
use Tests\Unit\Traits\TestPrivateMethod;
class ServiceTest extends TestCase
{
use TestPrivateMethod;
public function testFetchAndAssociateRelatedActivity(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$payloadBuilder->method('addCustomLogicFieldsPayload')
->willReturnCallback(function ($activity, $payload) {
return $payload;
});
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods(['fetchRelatedActivity', 'getPlaybook', 'getPlaybookCategory', 'updateRecord'])
->getMock();
$serviceMock->expects($this->once())
->method('fetchRelatedActivity')
->willReturn([
'Id' => 'testId',
'Type' => null,
'OwnerId' => 'testerUser',
'Description' => 'Test description',
]);
$user = $this->createMock(User::class);
$team = $this->createMock(Team::class);
$user->method('getAttribute')->with('team')->willReturn($team);
$playbook = $this->createMock(Playbook::class);
$playbook->method('getActivityField')->willReturn(null);
$playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_EVENT);
$serviceMock->expects($this->once())
->method('getPlaybook')
->with($user)
->willReturn($playbook);
$serviceMock->expects($this->never())
->method('getPlaybookCategory');
$serviceMock->expects($this->never())
->method('updateRecord');
$fieldDataRepository = $this->createMock(FieldDataRepository::class);
$fieldDataRepository->method('getActivityFieldData')->willReturn(collect([]));
app()->instance(FieldDataRepository::class, $fieldDataRepository);
$config = $this->createMock(Configuration::class);
$profilesRelation = $this->getMockBuilder(\Illuminate\Database\Eloquent\Relations\HasMany::class)
->disableOriginalConstructor()
->onlyMethods(['get'])
->addMethods(['where'])
->getMock();
$profilesRelation->method('where')->willReturnSelf();
$profilesRelation->method('get')->willReturn(collect([]));
$config->method('profiles')->willReturn($profilesRelation);
$serviceMock->config = $config;
$serviceMock->profile = null;
$actualStartTime = \Carbon\Carbon::now();
$activity = $this->getMockBuilder(Activity::class)
->disableOriginalConstructor()
->onlyMethods(['update', 'hasProspect'])
->getMock();
$activity->method('update')->willReturn(true);
$activity->method('hasProspect')->willReturn(true);
$activity->type = Activity::TYPE_CONFERENCE;
$activity->provider = Activity::PROVIDER_TWILIO;
$activity->lead_id = 1;
$activity->user_id = 0;
$activity->id_string = 'test-activity-id';
$activity->user = $user;
$activity->actual_start_time = $actualStartTime;
$activity->uuid = 'c53d8320-f556-4cee-a2f8-5f232f454ca4';
app()->bind(PlaybookResolver::class, function () use ($user) {
$playbook = $this->createMock(Playbook::class);
$playbookResolver = $this->createMock(PlaybookResolver::class);
$playbookResolver->expects($this->once())
->method('resolvePlaybookByUser')
->with($user)
->willReturn($playbook);
return $playbookResolver;
});
$data = $serviceMock->fetchAndAssociateRelatedActivity($activity);
$this->assertInstanceOf(Activity::class, $data);
$this->assertEquals(Activity::TYPE_CONFERENCE, $data->getType());
$this->assertEquals($actualStartTime->getTimestamp(), $data->getActualStartTime()->getTimestamp());
}
public function testFetchAndAssociateRelatedActivitySkipsForTaskBasedPlaybook(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods(['fetchRelatedActivity', 'getPlaybook'])
->getMock();
$user = $this->createMock(User::class);
$playbook = $this->createMock(Playbook::class);
$playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);
$playbook->method('getId')->willReturn(123);
$serviceMock->expects($this->once())
->method('getPlaybook')
->with($user)
->willReturn($playbook);
$serviceMock->expects($this->never())
->method('fetchRelatedActivity');
$activity = $this->getMockBuilder(Activity::class)
->disableOriginalConstructor()
->onlyMethods(['hasProspect', 'getUuid'])
->getMock();
$activity->method('hasProspect')->willReturn(true);
$activity->method('getUuid')->willReturn('c53d8320-f556-4cee-a2f8-5f232f454ca4');
$activity->type = Activity::TYPE_CONFERENCE;
$activity->actual_start_time = \Carbon\Carbon::now();
$activity->user = $user;
$result = $serviceMock->fetchAndAssociateRelatedActivity($activity);
$this->assertNull($result);
}
public function testFetchAndAssociateRelatedActivityReturnsNullForNonConference(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$serviceMock = new Service(
client: $client,
payloadBuilder: $payloadBuilder,
eventDispatcher: $eventDispatcher,
countriesMap: $countriesMap,
prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)
);
$activity = $this->getMockBuilder(Activity::class)
->disableOriginalConstructor()
->getMock();
$activity->type = Activity::TYPE_SOFTPHONE;
$result = $serviceMock->fetchAndAssociateRelatedActivity($activity);
$this->assertNull($result);
}
public function testFetchAndAssociateRelatedActivityReturnsNullWhenNoStartTime(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$serviceMock = new Service(
client: $client,
payloadBuilder: $payloadBuilder,
eventDispatcher: $eventDispatcher,
countriesMap: $countriesMap,
prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)
);
$activity = $this->getMockBuilder(Activity::class)
->disableOriginalConstructor()
->getMock();
$activity->type = Activity::TYPE_CONFERENCE;
$activity->actual_start_time = null;
$activity->scheduled_start_time = null;
$result = $serviceMock->fetchAndAssociateRelatedActivity($activity);
$this->assertNull($result);
}
public function testFetchAndAssociateRelatedActivityReturnsNullWhenNoProspect(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods(['getPlaybook'])
->getMock();
$serviceMock->expects($this->never())
->method('getPlaybook');
$activity = $this->getMockBuilder(Activity::class)
->disableOriginalConstructor()
->onlyMethods(['hasProspect', 'getUuid'])
->getMock();
$activity->method('hasProspect')->willReturn(false);
$activity->method('getUuid')->willReturn('c53d8320-f556-4cee-a2f8-5f232f454ca4');
$activity->type = Activity::TYPE_CONFERENCE;
$activity->actual_start_time = \Carbon\Carbon::now();
$result = $serviceMock->fetchAndAssociateRelatedActivity($activity);
$this->assertNull($result);
}
public function testMatchExactlyByEmail(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods([])
->getMock();
$profile = new Profile();
$profile->setAttribute('id', bin2hex(random_bytes(8)));
$serviceMock->profile = $profile;
$team = $this->createMock(Team::class);
$serviceMock->team = $team;
$data = $serviceMock->matchExactlyByEmail(bin2hex(random_bytes(8)) . '[EMAIL]');
$this->assertEquals(null, $data);
}
public function testMatchDomainFromEmail(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$queryIterator = $this->createMock(QueryIterator::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$config = $this->createMock(Configuration::class);
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('search')
->willReturn($queryIterator);
return $handler;
});
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods(['convertCrmData'])
->getMock();
$profile = new Profile();
$profile->account_fields = 'Field1, Field2, Field3';
$serviceMock->profile = $profile;
$serviceMock->expects($this->once())
->method('convertCrmData')
->willReturn(['test']);
$this->app->bind(QueryBuilder::class, function () {
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->expects($this->once())
->method('buildMatchByDomainQuery')
->with('[EMAIL]')
->willReturn('FIND {[EMAIL]} IN ALL FIELDS RETURNING Account(Id)');
return $queryBuilder;
});
$team = $this->createMock(Team::class);
$serviceMock->team = $team;
$serviceMock->config = $config;
$data = $serviceMock->matchByDomain('[EMAIL]');
$this->assertEquals(['test'], $data);
}
public function testBuildTaskSearchFields(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$service = new Service(
client: $client,
payloadBuilder: $payloadBuilder,
eventDispatcher: $eventDispatcher,
countriesMap: $countriesMap,
prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)
);
$fields = $service->buildTaskSearchFields();
$expectedFields = ['Id', 'WhoId', 'WhatId', 'AccountId'];
$this->assertEquals($expectedFields, $fields);
}
public function testMapCrmObjects(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$service = new Service(
client: $client,
payloadBuilder: $payloadBuilder,
eventDispatcher: $eventDispatcher,
countriesMap: $countriesMap,
prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class),
);
$sampleTask = [
'WhoId' => '003sampleWhoId',
'AccountId' => 'sampleAccountId',
'WhatId' => 'sampleWhatId',
];
$activityData = $service->mapCrmObjects($sampleTask);
$expectedActivityData = [
'contact' => '003sampleWhoId',
'account' => 'sampleAccountId',
'opportunity' => 'sampleWhatId',
];
$this->assertEquals($expectedActivityData, $activityData);
}
public function testGetInstalledAppVersion(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$queryIterator = $this->createMock(QueryIterator::class);
$queryIterator->expects($this->any())
->method('current')->willReturn([
'SubscriberPackageVersion' => [
'MajorVersion' => '1',
'MinorVersion' => '0',
'PatchVersion' => '1',
'BuildNumber' => '0',
],
]);
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('metadata')
->willReturn($queryIterator);
return $handler;
});
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods(array_diff(get_class_methods(Service::class), ['getInstalledAppVersion']))
->getMock();
$version = $serviceMock->getInstalledAppVersion();
$this->assertEquals('1010', $version);
}
public function testSyncProfiles(): void
{
$userToSearch = null;
$team = $this->createMock(Team::class);
$config = $this->createMockedConfiguration();
$config->expects($this->once())
->method('getId')
->willReturn(1);
$salesforceUser = [
'Email' => '[EMAIL]',
'UserPreferencesLightningExperiencePreferred' => true,
'CallCenterId' => '123',
'Id' => '456',
'ProfileId' => '789',
];
app()->bind(QueryBuilder::class, function () use ($userToSearch) {
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->expects($this->once())
->method('buildGetUsersQuery')
->with($userToSearch)
->willReturn('SELECT * FROM Users');
return $queryBuilder;
});
$queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('query')
->with('SELECT * FROM Users')
->willReturn($queryIterator);
return $handler;
});
$user = $this->createMock(User::class);
$user->expects($this->once())
->method('getId')
->willReturn(123);
$this->mockTeamRepository($team, $salesforceUser, $user);
$profileRepository = $this->createMock(ProfileRepository::class);
$profileRepository->expects($this->once())
->method('updateOrCreateProfile')
->with(
$user,
[
'crm_configuration_id' => 1,
'crm_provider_id' => '456',
],
[
'user_id' => 123,
'edition' => Profile::EDITION_LIGHTNING,
'has_external_cti' => true,
'crm_profile_id' => '789',
]
)
->willReturn(new Profile());
$this->app->instance(ProfileRepository::class, $profileRepository);
$serviceMock = $this->getServiceMock();
$serviceMock->team = $team;
$serviceMock->config = $config;
$result = $serviceMock->syncProfiles($userToSearch);
$this->assertNull($result);
}
public function testSyncProfilesEmailIsNull(): void
{
$userToSearch = $this->createMock(User::class);
$salesforceUser = [
'Email' => null,
];
app()->bind(QueryBuilder::class, function () {
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->expects($this->once())
->method('buildGetUsersQuery')
->with(null)
->willReturn('SELECT * FROM Users');
return $queryBuilder;
});
$queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->exactly(2))
->method('query')
->willReturn($queryIterator);
return $handler;
});
$team = $this->createMock(Team::class);
$user = $this->createMock(User::class);
$this->mockTeamRepository($team, $salesforceUser, $user, false);
$config = $this->createMock(Configuration::class);
$serviceMock = $this->getServiceMock();
$serviceMock->team = $team;
$serviceMock->config = $config;
$profile = $serviceMock->syncProfiles(null);
$this->assertNull($profile);
}
public function testSyncProfilesUserToSearchMatchesCurrentUser(): void
{
$userToSearch = $this->createMock(User::class);
$userToSearch->expects($this->once())
->method('getId')
->willReturn(123);
$team = $this->createMock(Team::class);
$config = $this->createMock(Configuration::class);
$config->expects($this->once())
->method('getId')
->willReturn(1);
$salesforceUser = [
'Email' => '[EMAIL]',
'UserPreferencesLightningExperiencePreferred' => true,
'CallCenterId' => '123',
'Id' => '456',
'ProfileId' => '789',
];
app()->bind(QueryBuilder::class, function () use ($userToSearch) {
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->expects($this->once())
->method('buildGetUsersQuery')
->with($userToSearch)
->willReturn('SELECT * FROM Users');
return $queryBuilder;
});
$queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('query')
->with('SELECT * FROM Users')
->willReturn($queryIterator);
return $handler;
});
$user = $this->createMock(User::class);
$user->expects($this->exactly(2))
->method('getId')
->willReturn(123);
$this->mockTeamRepository($team, $salesforceUser, $user);
$profileRepository = $this->createMock(ProfileRepository::class);
$profileRepository->expects($this->once())
->method('updateOrCreateProfile')
->with(
$user,
[
'crm_configuration_id' => 1,
'crm_provider_id' => '456',
],
[
'user_id' => 123,
'edition' => Profile::EDITION_LIGHTNING,
'has_external_cti' => true,
'crm_profile_id' => '789',
]
)
->willReturn(new Profile());
$this->app->instance(ProfileRepository::class, $profileRepository);
$serviceMock = $this->getServiceMock();
$serviceMock->team = $team;
$serviceMock->config = $config;
$profile = $serviceMock->syncProfiles($userToSearch);
$this->assertInstanceOf(Profile::class, $profile);
}
public function testSyncProfilesWithCustomValidation(): void
{
$userToSearch = null;
$team = $this->createMock(Team::class);
$config = $this->createMockedConfiguration();
$config->expects($this->atLeastOnce()) // Changed from once() to atLeastOnce()
->method('getId')
->willReturn(1);
$salesforceUser = [
'Email' => '[EMAIL]',
'UserPreferencesLightningExperiencePreferred' => true,
'CallCenterId' => '123',
'Id' => '456',
'ProfileId' => '789',
'CustomField' => 'CustomValue',
];
$customRules = [
['field' => 'CustomField', 'value' => 'CustomValue'],
];
$this->mockQueryBuilderAndHandler($userToSearch, [$salesforceUser]);
$user = $this->createMock(User::class);
$user->expects($this->once())
->method('getId')
->willReturn(123);
$this->mockTeamRepository($team, $salesforceUser, $user, true, $customRules);
$this->mockProfileRepository($user);
$serviceMock = $this->getServiceMock();
$serviceMock->team = $team;
$serviceMock->config = $config;
$profile = $serviceMock->syncProfiles($userToSearch);
$this->assertNull($profile);
}
public function testSyncProfilesWithCustomValidationFailing(): void
{
$userToSearch = null;
$team = $this->createMock(Team::class);
$config = $this->createMockedConfiguration();
$salesforceUser = [
'Email' => '[EMAIL]',
'UserPreferencesLightningExperiencePreferred' => true,
'CallCenterId' => '123',
'Id' => '456',
'ProfileId' => '789',
'CustomField' => 'WrongValue',
];
$customRules = [
['field' => 'CustomField', 'value' => 'CustomValue'],
];
$this->mockQueryBuilderAndHandler($userToSearch, [$salesforceUser]);
$teamRepository = $this->getMockForAbstractClass(TeamRepository::class, [], '', false, true, true, ['findActiveTeamMemberByEmail', 'getTeamSetting']);
$teamSettings = $this->createMock(TeamSettings::class);
$teamSettings->method('getValueType')
->willReturn('array');
$teamSettings->method('getValue')
->willReturn(json_encode($customRules));
$teamRepository->expects($this->once())
->method('getTeamSetting')
->with($team, 'custom_profile_validation')
->willReturn($teamSettings);
app()->bind(TeamRepository::class, function () use ($teamRepository) {
return $teamRepository;
});
$profileRepository = $this->createMock(ProfileRepository::class);
$profileRepository->expects($this->never())
->method('updateOrCreateProfile');
$this->app->instance(ProfileRepository::class, $profileRepository);
$serviceMock = $this->getServiceMock();
$serviceMock->team = $team;
$serviceMock->config = $config;
$result = $serviceMock->syncProfiles($userToSearch);
$this->assertNull($result);
}
public function testGetContactRolesFromCrm(): void
{
$contactRoles = [
[
'Id' => '1',
'ContactId' => 'Contact1',
'OpportunityId' => 'Opportunity1',
'Opportunity' => ['OwnerId' => 'Owner1'],
'IsPrimary' => true,
'Role' => 'Decision Maker',
],
];
$expectedResponse = [
[
'id' => '1',
'contactId' => 'Contact1',
'opportunityId' => 'Opportunity1',
'ownerId' => 'Owner1',
'isPrimary' => true,
'role' => 'Decision Maker',
],
];
$this->bindQueryIterator($contactRoles);
$serviceMock = $this->getServiceMock();
$data = $serviceMock->getContactRolesFromCrm(now()->subDay());
$this->assertEquals($expectedResponse, $data);
}
public function testGetContactRolesFromCrmNoResult(): void
{
$this->bindQueryIterator([]);
$serviceMock = $this->getServiceMock();
$data = $serviceMock->getContactRolesFromCrm(now()->subDay());
$this->assertEquals([], $data);
}
public function testSyncContactRoles(): void
{
$contactRoles = [
[
'id' => '1',
'contactId' => 'Contact1',
'opportunityId' => 'Opportunity1',
'ownerId' => 'Owner1',
'isPrimary' => true,
'role' => 'Decision Maker',
],
];
app()->bind(ContactRoleRepository::class, function () {
$contactRoleRepository = $this->createMock(ContactRoleRepository::class);
$contactRoleRepository->expects($this->once())
->method('saveContactRoles');
return $contactRoleRepository;
});
$serviceMock = $this->getServiceMock([
'getContactRolesFromCrm',
'syncRemotelyDeletedContactRoles',
'syncContact',
'syncOpportunity',
]);
$config = $this->createMock(Configuration::class);
$hasMany = $this->createMock(HasManyExtended::class);
$hasMany->expects($this->exactly(2))
->method('where')
->willReturn($hasMany);
$hasMany->expects($this->exactly(2))
->method('first')
->willReturn(
$this->createMock(Contact::class),
$this->createMock(Opportunity::class)
);
$config->expects($this->once())
->method('contacts')
->willReturn($hasMany);
$config->expects($this->once())
->method('opportunities')
->willReturn($hasMany);
$serviceMock->config = $config;
$serviceMock->expects($this->once())
->method('getContactRolesFromCrm')
->willReturn($contactRoles);
$serviceMock->expects($this->once())
->method('syncRemotelyDeletedContactRoles');
$data = $serviceMock->syncContactRoles(now()->subDay());
$this->assertEquals(1, $data);
}
public function testSyncRemotelyDeletedContactRoles(): void
{
$contactRoles = [
[
'id' => '1',
'crm_provider_id' => '1',
],
];
app()->bind(QueryHandler::class, function () use ($contactRoles) {
$queryResults = new QueryResults($contactRoles, 1, true, null);
$handler = $this->createMock(QueryHandler::class);
$handler->method('queryDeleted')
->willReturn($queryResults);
return $handler;
});
app()->bind(ContactRoleRepository::class, function () {
$contactRoleRepository = $this->createMock(ContactRoleRepository::class);
$contactRoleRepository->expects($this->once())
->method('deleteContactRoles');
return $contactRoleRepository;
});
$serviceMock = $this->getServiceMock();
$serviceMock->team = $this->createMock(Team::class);
$data = $this->invokePrivateMethod('syncRemotelyDeletedContactRoles', $serviceMock, []);
$this->assertTrue($data);
}
private function bindQueryIterator(array $queryResult): void
{
/** @var Client $client */
$client = $this->createMock(Client::class);
$queryIterator = new QueryIterator(
$client,
new QueryResults($queryResult, 1, true, null)
);
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('query')
->willReturn($queryIterator);
return $handler;
});
}
public static function getOpportunitySortOrderDataProvider(): array
{
return [
'all open recently updated' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED, ['LastModifiedDate DESC', true]],
'all open recently created' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED, ['CreatedDate DESC', true]],
'all open oldest created' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED, ['CreatedDate ASC', true]],
'all recently updated' => [Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED, ['LastModifiedDate DESC', false]],
'default' => ['unknown', ['LastModifiedDate DESC', true]],
];
}
private function createMockedConfiguration(): Configuration
{
$config = $this->createMock(Configuration::class);
$profilesRelation = $this->getMockBuilder(\Illuminate\Database\Eloquent\Relations\HasMany::class)
->disableOriginalConstructor()
->onlyMethods(['get'])
->addMethods(['where', 'first'])
->getMock();
$profilesRelation->method('where')->willReturnSelf();
$profilesRelation->method('get')->willReturn(collect([]));
$profilesRelation->method('first')->willReturn(null);
$config->method('profiles')->willReturn($profilesRelation);
return $config;
}
private function getServiceMock(array $onlyMethods = []): MockObject&Service
{
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$this->createMock(Client::class),
$this->createMock(PayloadBuilder::class),
$this->createMock(Dispatcher::class),
$this->createMock(CountriesMap::class),
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods($onlyMethods)
->getMock();
$serviceMock->profile = $this->createMock(Profile::class);
return $serviceMock;
}
private function mockQueryBuilderAndHandler($userToSearch, $salesforceUsers): void
{
app()->bind(QueryBuilder::class, function () use ($userToSearch) {
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->expects($this->once())
->method('buildGetUsersQuery')
->with($userToSearch)
->willReturn('SELECT * FROM Users');
return $queryBuilder;
});
$queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults($salesforceUsers, count($salesforceUsers), true, null));
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('query')
->willReturn($queryIterator);
return $handler;
});
}
private function mockTeamRepository(
Team $team,
array $salesforceUser,
?User $user = null,
bool $userSearch = true,
array $customRules = []
): void {
$teamRepository = $this->getMockForAbstractClass(TeamRepository::class, [], '', false, true, true, ['findActiveTeamMemberByEmail', 'getTeamSetting']);
if ($userSearch) {
$teamRepository->expects($this->once())
->method('findActiveTeamMemberByEmail')
->with($team, $salesforceUser['Email'])
->willReturn($user);
}
$teamSettings = $this->createMock(TeamSettings::class);
$teamSettings->method('getValueType')
->willReturn('array');
$teamSettings->method('getValue')
->willReturn(json_encode($customRules));
$teamRepository->expects($this->once())
->method('getTeamSetting')
->with($team, 'custom_profile_validation')
->willReturn($teamSettings);
app()->bind(TeamRepository::class, function () use ($teamRepository) {
return $teamRepository;
});
}
private function mockProfileRepository(User $user): void
{
$profileRepository = $this->createMock(ProfileRepository::class);
$profileRepository->expects($this->once())
->method('updateOrCreateProfile')
->with(
$user,
[
'crm_configuration_id' => 1,
'crm_provider_id' => '456',
],
[
'user_id' => 123,
'edition' => Profile::EDITION_LIGHTNING,
'has_external_cti' => true,
'crm_profile_id' => '789',
]
)
->willReturn(new Profile());
$this->app->instance(ProfileRepository::class, $profileRepository);
}
public function testBuildEnhancedNoteDecodesHtmlEntities(): void
{
$service = $this->getServiceMock(['createRecord']);
$profile = new Profile();
$profile->setAttribute('crm_provider_id', 'owner-123');
$service->profile = $profile;
$service->expects($this->exactly(2))
->method('createRecord')
->willReturnOnConsecutiveCalls('note-id-123', 'link-id-456');
$bodyWithEntities = 'Welch's current challenges and Facebook's Club';
$result = $this->invokePrivateMethod('buildEnhancedNote', $service, [
'Test Title',
$bodyWithEntities,
'object-id-789',
]);
$this->assertEquals('note-id-123', $result);
}
public function testBuildEnhancedNoteSanitizesWithoutQuotes(): void
{
$service = $this->getServiceMock(['createRecord']);
$profile = new Profile();
$profile->setAttribute('crm_provider_id', 'owner-456');
$service->profile = $profile;
$service->expects($this->exactly(2))
->method('createRecord')
->willReturnCallback(function ($type, $data) {
if ($type === 'ContentNote') {
$decoded = base64_decode($data['Content']);
$this->assertStringContainsString("Welch's", $decoded);
$this->assertStringNotContainsString(''', $decoded);
$this->assertStringNotContainsString('&#039;', $decoded);
$this->assertStringContainsString('<script>', $decoded);
return 'note-id-456';
}
return 'link-id-789';
});
$bodyWithMixedContent = "Welch's and <script>alert('xss')</script>";
$result = $this->invokePrivateMethod('buildEnhancedNote', $service, [
'Test Title',
$bodyWithMixedContent,
'object-id-123',
]);
$this->assertEquals('note-id-456', $result);
}
public function testBuildEnhancedNoteConvertsLineBreaks(): void
{
$service = $this->getServiceMock(['createRecord']);
$profile = new Profile();
$profile->setAttribute('crm_provider_id', 'owner-789');
$service->profile = $profile;
$service->expects($this->exactly(2))
->method('createRecord')
->willReturnCallback(function ($type, $data) {
if ($type === 'ContentNote') {
$decoded = base64_decode($data['Content']);
$this->assertStringContainsString('<br>', $decoded);
$this->assertStringNotContainsString('<br />', $decoded);
return 'note-id-789';
}
return 'link-id-012';
});
$bodyWithLineBreaks = "Line 1\nLine 2\nLine 3";
$result = $this->invokePrivateMethod('buildEnhancedNote', $service, [
'Test Title',
$bodyWithLineBreaks,
'object-id-456',
]);
$this->assertEquals('note-id-789', $result);
}
public function testBuildEnhancedNoteHandlesComplexScenario(): void
{
$service = $this->getServiceMock(['createRecord']);
$profile = new Profile();
$profile->setAttribute('crm_provider_id', 'owner-complex');
$service->profile = $profile;
$service->expects($this->exactly(2))
->method('createRecord')
->willReturnCallback(function ($type, $data) {
if ($type === 'ContentNote') {
$decoded = base64_decode($data['Content']);
$this->assertStringContainsString("Welch's", $decoded);
$this->assertStringContainsString("Facebook's Club", $decoded);
$this->assertStringContainsString("Arctics'", $decoded);
$this->assertStringNotContainsString(''', $decoded);
$this->assertStringNotContainsString('&#039;', $decoded);
$this->assertStringContainsString('<br>', $decoded);
$this->assertStringContainsString('<', $decoded);
$this->assertStringContainsString('>', $decoded);
return 'note-complex';
}
return 'link-complex';
});
$complexBody = "Summary:\n---------\nThe call focused on understanding Welch's current challenges and exploring how Arctics' Revenue Growth Management solutions could support their strategic goals.\n\n• John SMith discussed his role as a category advisor for Google and Facebook's Club, emphasizing the importance of market research and advising on product assortment.\n• Madona introduced Arctics' Virtual Shoppers AI, which simulates consumer <behavior> to optimize pricing and promotional strategies.";
$result = $this->invokePrivateMethod('buildEnhancedNote', $service, [
'Jiminny Transcription Summary',
$complexBody,
'task-id-001',
]);
$this->assertEquals('note-complex', $result);
}
public function testSyncRemotelyDeletedObjectsWithErrorHandlingSuccess(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$prospectPhotoPathService,
])
->onlyMethods(['syncRemotelyDeletedObjects'])
->getMock();
// Mock team
$team = $this->createMock(Team::class);
$team->method('getUuid')->willReturn('team-uuid-123');
$serviceMock->team = $team;
// Expect syncRemotelyDeletedObjects to be called once and succeed
$serviceMock->expects($this->once())
->method('syncRemotelyDeletedObjects')
->with(\Jiminny\Enums\CrmObject::ACCOUNT);
// Call the protected method using reflection
$reflection = new \ReflectionClass($serviceMock);
$method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');
$method->setAccessible(true);
// Should not throw any exceptions
$method->invoke($serviceMock, \Jiminny\Enums\CrmObject::ACCOUNT);
$this->assertTrue(true); // Test completed successfully
}
public function testSyncRemotelyDeletedObjectsWithErrorHandlingFailure(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$prospectPhotoPathService,
])
->onlyMethods(['syncRemotelyDeletedObjects'])
->getMock();
// Mock team
$team = $this->createMock(Team::class);
$team->method('getUuid')->willReturn('team-uuid-456');
$serviceMock->team = $team;
// Mock logger to verify warning is logged
$logger = $this->createMock(\Psr\Log\LoggerInterface::class);
// Use reflection to set the protected logger property
$reflection = new \ReflectionClass($serviceMock);
$loggerProperty = $reflection->getProperty('logger');
$loggerProperty->setAccessible(true);
$loggerProperty->setValue($serviceMock, $logger);
$exception = new \Exception('Sync failed due to API error');
// Expect syncRemotelyDeletedObjects to throw an exception
$serviceMock->expects($this->once())
->method('syncRemotelyDeletedObjects')
->with(\Jiminny\Enums\CrmObject::CONTACT)
->willThrowException($exception);
// Expect warning to be logged with correct message and parameters
$logger->expects($this->once())
->method('warning')
->with(
'[Salesforce] Remotely deleted objects sync failed',
[
'objectType' => 'contact',
'teamId' => 'team-uuid-456',
'reason' => 'Sync failed due to API error',
]
);
// Call the protected method using reflection
$reflection = new \ReflectionClass($serviceMock);
$method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');
$method->setAccessible(true);
// Should not re-throw the exception, just log it
$method->invoke($serviceMock, \Jiminny\Enums\CrmObject::CONTACT);
$this->assertTrue(true); // Test completed successfully
}
public function testSyncRemotelyDeletedObjectsWithErrorHandlingWithLogParams(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$prospectPhotoPathService,
])
->onlyMethods(['syncRemotelyDeletedObjects'])
->getMock();
// Mock team
$team = $this->createMock(Team::class);
$team->method('getUuid')->willReturn('team-uuid-789');
$serviceMock->team = $team;
// Mock logger to verify warning is logged
$logger = $this->createMock(\Psr\Log\LoggerInterface::class);
// Use reflection to set the protected logger property
$loggerReflection = new \ReflectionClass($serviceMock);
$loggerProperty = $loggerReflection->getProperty('logger');
$loggerProperty->setAccessible(true);
$loggerProperty->setValue($serviceMock, $logger);
$exception = new \Exception('Network timeout');
// Expect syncRemotelyDeletedObjects to throw an exception
$serviceMock->expects($this->once())
->method('syncRemotelyDeletedObjects')
->with(\Jiminny\Enums\CrmObject::OPPORTUNITY)
->willThrowException($exception);
// Additional log parameters
$logParams = [
'syncType' => 'full',
'batchSize' => 100,
];
// Expect warning to be logged with merged parameters
$logger->expects($this->once())
->method('warning')
->with(
'[Salesforce] Remotely deleted objects sync failed',
[
'objectType' => 'opportunity',
'teamId' => 'team-uuid-789',
'reason' => 'Network timeout',
'syncType' => 'full',
'batchSize' => 100,
]
);
// Call the protected method using reflection
$reflection = new \ReflectionClass($serviceMock);
$method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');
...
|
[{"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-20963-fix-import-on-deleted-entity, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.09607713,"height":0.025538707},"on_screen":true,"help_text":"Git Branch: JY-20963-fix-import-on-deleted-entity","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.85638297,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"ServiceTest","depth":6,"bounds":{"left":0.87167555,"top":0.019952115,"width":0.043882977,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Rerun 'PHPUnit: ServiceTest'","depth":6,"bounds":{"left":0.9155585,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'ServiceTest'","depth":6,"bounds":{"left":0.9268617,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Stop 'ServiceTest'","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":"More Actions","depth":6,"bounds":{"left":0.9494681,"top":0.019952115,"width":0.011303191,"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":"Built-in Preview","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":"Chrome","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":"Firefox","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":"Safari","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":"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":"4","depth":4,"bounds":{"left":0.3567154,"top":0.10055866,"width":0.007978723,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"32","depth":4,"bounds":{"left":0.36668882,"top":0.10055866,"width":0.010305851,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"168","depth":4,"bounds":{"left":0.37898937,"top":0.10055866,"width":0.011968086,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"bounds":{"left":0.3929521,"top":0.10055866,"width":0.00731383,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"5","depth":4,"bounds":{"left":0.40226063,"top":0.10055866,"width":0.007978723,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.4119016,"top":0.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.4192154,"top":0.09896249,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Services\\Crm\\Salesforce;\n\nuse Illuminate\\Database\\Eloquent\\Relations\\HasMany;\nuse Illuminate\\Events\\Dispatcher;\nuse Illuminate\\Support\\Facades\\Cache;\nuse Jiminny\\Component\\Country\\CountriesMap;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Enums\\CrmObject;\nuse Jiminny\\Exceptions\\InvalidArgumentException;\nuse Jiminny\\Exceptions\\NoResultsException;\nuse Jiminny\\Integrations\\PlaybookResolver;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Crm\\Configuration;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\TeamSettings;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\ContactRoleRepository;\nuse Jiminny\\Repositories\\Crm\\FieldDataRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\Salesforce\\Client;\nuse Jiminny\\Services\\Crm\\Salesforce\\PayloadBuilder;\nuse Jiminny\\Services\\Crm\\Salesforce\\QueryBuilder;\nuse Jiminny\\Services\\Crm\\Salesforce\\QueryHandler;\nuse Jiminny\\Services\\Crm\\Salesforce\\QueryIterator;\nuse Jiminny\\Services\\Crm\\Salesforce\\QueryResults;\nuse Jiminny\\Services\\Crm\\Salesforce\\Service;\nuse PHPUnit\\Framework\\MockObject\\MockObject;\nuse Tests\\TestCase;\nuse Tests\\Unit\\Traits\\TestPrivateMethod;\n\nclass ServiceTest extends TestCase\n{\n use TestPrivateMethod;\n\n public function testFetchAndAssociateRelatedActivity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $payloadBuilder->method('addCustomLogicFieldsPayload')\n ->willReturnCallback(function ($activity, $payload) {\n return $payload;\n });\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods(['fetchRelatedActivity', 'getPlaybook', 'getPlaybookCategory', 'updateRecord'])\n ->getMock();\n\n $serviceMock->expects($this->once())\n ->method('fetchRelatedActivity')\n ->willReturn([\n 'Id' => 'testId',\n 'Type' => null,\n 'OwnerId' => 'testerUser',\n 'Description' => 'Test description',\n ]);\n\n $user = $this->createMock(User::class);\n $team = $this->createMock(Team::class);\n $user->method('getAttribute')->with('team')->willReturn($team);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityField')->willReturn(null);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_EVENT);\n\n $serviceMock->expects($this->once())\n ->method('getPlaybook')\n ->with($user)\n ->willReturn($playbook);\n\n $serviceMock->expects($this->never())\n ->method('getPlaybookCategory');\n\n $serviceMock->expects($this->never())\n ->method('updateRecord');\n\n $fieldDataRepository = $this->createMock(FieldDataRepository::class);\n $fieldDataRepository->method('getActivityFieldData')->willReturn(collect([]));\n app()->instance(FieldDataRepository::class, $fieldDataRepository);\n\n $config = $this->createMock(Configuration::class);\n $profilesRelation = $this->getMockBuilder(\\Illuminate\\Database\\Eloquent\\Relations\\HasMany::class)\n ->disableOriginalConstructor()\n ->onlyMethods(['get'])\n ->addMethods(['where'])\n ->getMock();\n $profilesRelation->method('where')->willReturnSelf();\n $profilesRelation->method('get')->willReturn(collect([]));\n $config->method('profiles')->willReturn($profilesRelation);\n\n $serviceMock->config = $config;\n $serviceMock->profile = null;\n\n $actualStartTime = \\Carbon\\Carbon::now();\n\n $activity = $this->getMockBuilder(Activity::class)\n ->disableOriginalConstructor()\n ->onlyMethods(['update', 'hasProspect'])\n ->getMock();\n\n $activity->method('update')->willReturn(true);\n $activity->method('hasProspect')->willReturn(true);\n\n $activity->type = Activity::TYPE_CONFERENCE;\n $activity->provider = Activity::PROVIDER_TWILIO;\n $activity->lead_id = 1;\n $activity->user_id = 0;\n $activity->id_string = 'test-activity-id';\n $activity->user = $user;\n\n $activity->actual_start_time = $actualStartTime;\n $activity->uuid = 'c53d8320-f556-4cee-a2f8-5f232f454ca4';\n\n app()->bind(PlaybookResolver::class, function () use ($user) {\n $playbook = $this->createMock(Playbook::class);\n $playbookResolver = $this->createMock(PlaybookResolver::class);\n $playbookResolver->expects($this->once())\n ->method('resolvePlaybookByUser')\n ->with($user)\n ->willReturn($playbook);\n\n return $playbookResolver;\n });\n\n $data = $serviceMock->fetchAndAssociateRelatedActivity($activity);\n\n $this->assertInstanceOf(Activity::class, $data);\n $this->assertEquals(Activity::TYPE_CONFERENCE, $data->getType());\n $this->assertEquals($actualStartTime->getTimestamp(), $data->getActualStartTime()->getTimestamp());\n }\n\n public function testFetchAndAssociateRelatedActivitySkipsForTaskBasedPlaybook(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods(['fetchRelatedActivity', 'getPlaybook'])\n ->getMock();\n\n $user = $this->createMock(User::class);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n $playbook->method('getId')->willReturn(123);\n\n $serviceMock->expects($this->once())\n ->method('getPlaybook')\n ->with($user)\n ->willReturn($playbook);\n\n $serviceMock->expects($this->never())\n ->method('fetchRelatedActivity');\n\n $activity = $this->getMockBuilder(Activity::class)\n ->disableOriginalConstructor()\n ->onlyMethods(['hasProspect', 'getUuid'])\n ->getMock();\n\n $activity->method('hasProspect')->willReturn(true);\n $activity->method('getUuid')->willReturn('c53d8320-f556-4cee-a2f8-5f232f454ca4');\n $activity->type = Activity::TYPE_CONFERENCE;\n $activity->actual_start_time = \\Carbon\\Carbon::now();\n $activity->user = $user;\n\n $result = $serviceMock->fetchAndAssociateRelatedActivity($activity);\n\n $this->assertNull($result);\n }\n\n public function testFetchAndAssociateRelatedActivityReturnsNullForNonConference(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $serviceMock = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)\n );\n\n $activity = $this->getMockBuilder(Activity::class)\n ->disableOriginalConstructor()\n ->getMock();\n\n $activity->type = Activity::TYPE_SOFTPHONE;\n\n $result = $serviceMock->fetchAndAssociateRelatedActivity($activity);\n\n $this->assertNull($result);\n }\n\n public function testFetchAndAssociateRelatedActivityReturnsNullWhenNoStartTime(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $serviceMock = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)\n );\n\n\n $activity = $this->getMockBuilder(Activity::class)\n ->disableOriginalConstructor()\n ->getMock();\n\n $activity->type = Activity::TYPE_CONFERENCE;\n $activity->actual_start_time = null;\n $activity->scheduled_start_time = null;\n\n $result = $serviceMock->fetchAndAssociateRelatedActivity($activity);\n\n $this->assertNull($result);\n }\n\n public function testFetchAndAssociateRelatedActivityReturnsNullWhenNoProspect(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods(['getPlaybook'])\n ->getMock();\n\n $serviceMock->expects($this->never())\n ->method('getPlaybook');\n\n $activity = $this->getMockBuilder(Activity::class)\n ->disableOriginalConstructor()\n ->onlyMethods(['hasProspect', 'getUuid'])\n ->getMock();\n\n $activity->method('hasProspect')->willReturn(false);\n $activity->method('getUuid')->willReturn('c53d8320-f556-4cee-a2f8-5f232f454ca4');\n $activity->type = Activity::TYPE_CONFERENCE;\n $activity->actual_start_time = \\Carbon\\Carbon::now();\n\n $result = $serviceMock->fetchAndAssociateRelatedActivity($activity);\n\n $this->assertNull($result);\n }\n\n public function testMatchExactlyByEmail(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods([])\n ->getMock();\n\n $profile = new Profile();\n $profile->setAttribute('id', bin2hex(random_bytes(8)));\n $serviceMock->profile = $profile;\n\n $team = $this->createMock(Team::class);\n $serviceMock->team = $team;\n\n $data = $serviceMock->matchExactlyByEmail(bin2hex(random_bytes(8)) . 'test_email@testserver.com');\n\n $this->assertEquals(null, $data);\n }\n\n public function testMatchDomainFromEmail(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $queryIterator = $this->createMock(QueryIterator::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $config = $this->createMock(Configuration::class);\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('search')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods(['convertCrmData'])\n ->getMock();\n\n $profile = new Profile();\n $profile->account_fields = 'Field1, Field2, Field3';\n $serviceMock->profile = $profile;\n\n $serviceMock->expects($this->once())\n ->method('convertCrmData')\n ->willReturn(['test']);\n\n $this->app->bind(QueryBuilder::class, function () {\n $queryBuilder = $this->createMock(QueryBuilder::class);\n $queryBuilder->expects($this->once())\n ->method('buildMatchByDomainQuery')\n ->with('test_email@testserver.com')\n ->willReturn('FIND {test_email@testserver.com} IN ALL FIELDS RETURNING Account(Id)');\n\n return $queryBuilder;\n });\n\n $team = $this->createMock(Team::class);\n\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n\n $data = $serviceMock->matchByDomain('test_email@testserver.com');\n\n $this->assertEquals(['test'], $data);\n }\n\n public function testBuildTaskSearchFields(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)\n );\n\n $fields = $service->buildTaskSearchFields();\n\n $expectedFields = ['Id', 'WhoId', 'WhatId', 'AccountId'];\n\n $this->assertEquals($expectedFields, $fields);\n }\n\n public function testMapCrmObjects(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class),\n );\n\n $sampleTask = [\n 'WhoId' => '003sampleWhoId',\n 'AccountId' => 'sampleAccountId',\n 'WhatId' => 'sampleWhatId',\n ];\n\n $activityData = $service->mapCrmObjects($sampleTask);\n\n $expectedActivityData = [\n 'contact' => '003sampleWhoId',\n 'account' => 'sampleAccountId',\n 'opportunity' => 'sampleWhatId',\n ];\n\n $this->assertEquals($expectedActivityData, $activityData);\n }\n\n public function testGetInstalledAppVersion(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n $queryIterator = $this->createMock(QueryIterator::class);\n $queryIterator->expects($this->any())\n ->method('current')->willReturn([\n 'SubscriberPackageVersion' => [\n 'MajorVersion' => '1',\n 'MinorVersion' => '0',\n 'PatchVersion' => '1',\n 'BuildNumber' => '0',\n ],\n ]);\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('metadata')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods(array_diff(get_class_methods(Service::class), ['getInstalledAppVersion']))\n ->getMock();\n\n $version = $serviceMock->getInstalledAppVersion();\n\n $this->assertEquals('1010', $version);\n }\n\n public function testSyncProfiles(): void\n {\n $userToSearch = null;\n\n $team = $this->createMock(Team::class);\n $config = $this->createMockedConfiguration();\n $config->expects($this->once())\n ->method('getId')\n ->willReturn(1);\n\n $salesforceUser = [\n 'Email' => 'test@example.com',\n 'UserPreferencesLightningExperiencePreferred' => true,\n 'CallCenterId' => '123',\n 'Id' => '456',\n 'ProfileId' => '789',\n ];\n\n app()->bind(QueryBuilder::class, function () use ($userToSearch) {\n $queryBuilder = $this->createMock(QueryBuilder::class);\n $queryBuilder->expects($this->once())\n ->method('buildGetUsersQuery')\n ->with($userToSearch)\n ->willReturn('SELECT * FROM Users');\n\n return $queryBuilder;\n });\n\n $queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('query')\n ->with('SELECT * FROM Users')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n\n $user = $this->createMock(User::class);\n $user->expects($this->once())\n ->method('getId')\n ->willReturn(123);\n\n $this->mockTeamRepository($team, $salesforceUser, $user);\n\n $profileRepository = $this->createMock(ProfileRepository::class);\n $profileRepository->expects($this->once())\n ->method('updateOrCreateProfile')\n ->with(\n $user,\n [\n 'crm_configuration_id' => 1,\n 'crm_provider_id' => '456',\n ],\n [\n 'user_id' => 123,\n 'edition' => Profile::EDITION_LIGHTNING,\n 'has_external_cti' => true,\n 'crm_profile_id' => '789',\n ]\n )\n ->willReturn(new Profile());\n\n $this->app->instance(ProfileRepository::class, $profileRepository);\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n $result = $serviceMock->syncProfiles($userToSearch);\n\n $this->assertNull($result);\n }\n\n public function testSyncProfilesEmailIsNull(): void\n {\n $userToSearch = $this->createMock(User::class);\n\n $salesforceUser = [\n 'Email' => null,\n ];\n\n app()->bind(QueryBuilder::class, function () {\n $queryBuilder = $this->createMock(QueryBuilder::class);\n $queryBuilder->expects($this->once())\n ->method('buildGetUsersQuery')\n ->with(null)\n ->willReturn('SELECT * FROM Users');\n\n return $queryBuilder;\n });\n\n $queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->exactly(2))\n ->method('query')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n\n $team = $this->createMock(Team::class);\n $user = $this->createMock(User::class);\n $this->mockTeamRepository($team, $salesforceUser, $user, false);\n\n $config = $this->createMock(Configuration::class);\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n\n $profile = $serviceMock->syncProfiles(null);\n\n $this->assertNull($profile);\n }\n\n public function testSyncProfilesUserToSearchMatchesCurrentUser(): void\n {\n $userToSearch = $this->createMock(User::class);\n $userToSearch->expects($this->once())\n ->method('getId')\n ->willReturn(123);\n\n $team = $this->createMock(Team::class);\n $config = $this->createMock(Configuration::class);\n $config->expects($this->once())\n ->method('getId')\n ->willReturn(1);\n\n $salesforceUser = [\n 'Email' => 'test@example.com',\n 'UserPreferencesLightningExperiencePreferred' => true,\n 'CallCenterId' => '123',\n 'Id' => '456',\n 'ProfileId' => '789',\n ];\n\n app()->bind(QueryBuilder::class, function () use ($userToSearch) {\n $queryBuilder = $this->createMock(QueryBuilder::class);\n $queryBuilder->expects($this->once())\n ->method('buildGetUsersQuery')\n ->with($userToSearch)\n ->willReturn('SELECT * FROM Users');\n\n return $queryBuilder;\n });\n\n $queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('query')\n ->with('SELECT * FROM Users')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n\n $user = $this->createMock(User::class);\n $user->expects($this->exactly(2))\n ->method('getId')\n ->willReturn(123);\n\n $this->mockTeamRepository($team, $salesforceUser, $user);\n\n $profileRepository = $this->createMock(ProfileRepository::class);\n $profileRepository->expects($this->once())\n ->method('updateOrCreateProfile')\n ->with(\n $user,\n [\n 'crm_configuration_id' => 1,\n 'crm_provider_id' => '456',\n ],\n [\n 'user_id' => 123,\n 'edition' => Profile::EDITION_LIGHTNING,\n 'has_external_cti' => true,\n 'crm_profile_id' => '789',\n ]\n )\n ->willReturn(new Profile());\n\n $this->app->instance(ProfileRepository::class, $profileRepository);\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n $profile = $serviceMock->syncProfiles($userToSearch);\n\n $this->assertInstanceOf(Profile::class, $profile);\n }\n\n public function testSyncProfilesWithCustomValidation(): void\n {\n $userToSearch = null;\n\n $team = $this->createMock(Team::class);\n $config = $this->createMockedConfiguration();\n $config->expects($this->atLeastOnce()) // Changed from once() to atLeastOnce()\n ->method('getId')\n ->willReturn(1);\n\n $salesforceUser = [\n 'Email' => 'test@example.com',\n 'UserPreferencesLightningExperiencePreferred' => true,\n 'CallCenterId' => '123',\n 'Id' => '456',\n 'ProfileId' => '789',\n 'CustomField' => 'CustomValue',\n ];\n\n $customRules = [\n ['field' => 'CustomField', 'value' => 'CustomValue'],\n ];\n\n $this->mockQueryBuilderAndHandler($userToSearch, [$salesforceUser]);\n\n $user = $this->createMock(User::class);\n $user->expects($this->once())\n ->method('getId')\n ->willReturn(123);\n\n $this->mockTeamRepository($team, $salesforceUser, $user, true, $customRules);\n\n $this->mockProfileRepository($user);\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n $profile = $serviceMock->syncProfiles($userToSearch);\n\n $this->assertNull($profile);\n }\n\n public function testSyncProfilesWithCustomValidationFailing(): void\n {\n $userToSearch = null;\n\n $team = $this->createMock(Team::class);\n $config = $this->createMockedConfiguration();\n\n $salesforceUser = [\n 'Email' => 'test@example.com',\n 'UserPreferencesLightningExperiencePreferred' => true,\n 'CallCenterId' => '123',\n 'Id' => '456',\n 'ProfileId' => '789',\n 'CustomField' => 'WrongValue',\n ];\n\n $customRules = [\n ['field' => 'CustomField', 'value' => 'CustomValue'],\n ];\n\n $this->mockQueryBuilderAndHandler($userToSearch, [$salesforceUser]);\n\n $teamRepository = $this->getMockForAbstractClass(TeamRepository::class, [], '', false, true, true, ['findActiveTeamMemberByEmail', 'getTeamSetting']);\n\n $teamSettings = $this->createMock(TeamSettings::class);\n $teamSettings->method('getValueType')\n ->willReturn('array');\n\n $teamSettings->method('getValue')\n ->willReturn(json_encode($customRules));\n\n $teamRepository->expects($this->once())\n ->method('getTeamSetting')\n ->with($team, 'custom_profile_validation')\n ->willReturn($teamSettings);\n\n app()->bind(TeamRepository::class, function () use ($teamRepository) {\n return $teamRepository;\n });\n\n $profileRepository = $this->createMock(ProfileRepository::class);\n $profileRepository->expects($this->never())\n ->method('updateOrCreateProfile');\n\n $this->app->instance(ProfileRepository::class, $profileRepository);\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n $result = $serviceMock->syncProfiles($userToSearch);\n\n $this->assertNull($result);\n }\n\n public function testGetContactRolesFromCrm(): void\n {\n $contactRoles = [\n [\n 'Id' => '1',\n 'ContactId' => 'Contact1',\n 'OpportunityId' => 'Opportunity1',\n 'Opportunity' => ['OwnerId' => 'Owner1'],\n 'IsPrimary' => true,\n 'Role' => 'Decision Maker',\n ],\n ];\n\n $expectedResponse = [\n [\n 'id' => '1',\n 'contactId' => 'Contact1',\n 'opportunityId' => 'Opportunity1',\n 'ownerId' => 'Owner1',\n 'isPrimary' => true,\n 'role' => 'Decision Maker',\n ],\n ];\n\n $this->bindQueryIterator($contactRoles);\n\n $serviceMock = $this->getServiceMock();\n\n $data = $serviceMock->getContactRolesFromCrm(now()->subDay());\n\n $this->assertEquals($expectedResponse, $data);\n }\n\n public function testGetContactRolesFromCrmNoResult(): void\n {\n $this->bindQueryIterator([]);\n\n $serviceMock = $this->getServiceMock();\n\n $data = $serviceMock->getContactRolesFromCrm(now()->subDay());\n\n $this->assertEquals([], $data);\n }\n\n public function testSyncContactRoles(): void\n {\n $contactRoles = [\n [\n 'id' => '1',\n 'contactId' => 'Contact1',\n 'opportunityId' => 'Opportunity1',\n 'ownerId' => 'Owner1',\n 'isPrimary' => true,\n 'role' => 'Decision Maker',\n ],\n ];\n\n app()->bind(ContactRoleRepository::class, function () {\n $contactRoleRepository = $this->createMock(ContactRoleRepository::class);\n $contactRoleRepository->expects($this->once())\n ->method('saveContactRoles');\n\n return $contactRoleRepository;\n });\n\n $serviceMock = $this->getServiceMock([\n 'getContactRolesFromCrm',\n 'syncRemotelyDeletedContactRoles',\n 'syncContact',\n 'syncOpportunity',\n ]);\n\n $config = $this->createMock(Configuration::class);\n $hasMany = $this->createMock(HasManyExtended::class);\n $hasMany->expects($this->exactly(2))\n ->method('where')\n ->willReturn($hasMany);\n\n $hasMany->expects($this->exactly(2))\n ->method('first')\n ->willReturn(\n $this->createMock(Contact::class),\n $this->createMock(Opportunity::class)\n );\n\n $config->expects($this->once())\n ->method('contacts')\n ->willReturn($hasMany);\n $config->expects($this->once())\n ->method('opportunities')\n ->willReturn($hasMany);\n\n $serviceMock->config = $config;\n\n $serviceMock->expects($this->once())\n ->method('getContactRolesFromCrm')\n ->willReturn($contactRoles);\n\n $serviceMock->expects($this->once())\n ->method('syncRemotelyDeletedContactRoles');\n\n $data = $serviceMock->syncContactRoles(now()->subDay());\n\n $this->assertEquals(1, $data);\n }\n\n public function testSyncRemotelyDeletedContactRoles(): void\n {\n $contactRoles = [\n [\n 'id' => '1',\n 'crm_provider_id' => '1',\n ],\n ];\n\n app()->bind(QueryHandler::class, function () use ($contactRoles) {\n $queryResults = new QueryResults($contactRoles, 1, true, null);\n\n $handler = $this->createMock(QueryHandler::class);\n $handler->method('queryDeleted')\n ->willReturn($queryResults);\n\n return $handler;\n });\n\n app()->bind(ContactRoleRepository::class, function () {\n $contactRoleRepository = $this->createMock(ContactRoleRepository::class);\n $contactRoleRepository->expects($this->once())\n ->method('deleteContactRoles');\n\n return $contactRoleRepository;\n });\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $this->createMock(Team::class);\n\n $data = $this->invokePrivateMethod('syncRemotelyDeletedContactRoles', $serviceMock, []);\n\n $this->assertTrue($data);\n }\n\n private function bindQueryIterator(array $queryResult): void\n {\n /** @var Client $client */\n $client = $this->createMock(Client::class);\n $queryIterator = new QueryIterator(\n $client,\n new QueryResults($queryResult, 1, true, null)\n );\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('query')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n }\n\n public static function getOpportunitySortOrderDataProvider(): array\n {\n return [\n 'all open recently updated' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED, ['LastModifiedDate DESC', true]],\n 'all open recently created' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED, ['CreatedDate DESC', true]],\n 'all open oldest created' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED, ['CreatedDate ASC', true]],\n 'all recently updated' => [Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED, ['LastModifiedDate DESC', false]],\n 'default' => ['unknown', ['LastModifiedDate DESC', true]],\n ];\n }\n\n private function createMockedConfiguration(): Configuration\n {\n $config = $this->createMock(Configuration::class);\n $profilesRelation = $this->getMockBuilder(\\Illuminate\\Database\\Eloquent\\Relations\\HasMany::class)\n ->disableOriginalConstructor()\n ->onlyMethods(['get'])\n ->addMethods(['where', 'first'])\n ->getMock();\n $profilesRelation->method('where')->willReturnSelf();\n $profilesRelation->method('get')->willReturn(collect([]));\n $profilesRelation->method('first')->willReturn(null);\n $config->method('profiles')->willReturn($profilesRelation);\n\n return $config;\n }\n\n private function getServiceMock(array $onlyMethods = []): MockObject&Service\n {\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $this->createMock(Client::class),\n $this->createMock(PayloadBuilder::class),\n $this->createMock(Dispatcher::class),\n $this->createMock(CountriesMap::class),\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods($onlyMethods)\n ->getMock();\n\n $serviceMock->profile = $this->createMock(Profile::class);\n\n return $serviceMock;\n }\n\n private function mockQueryBuilderAndHandler($userToSearch, $salesforceUsers): void\n {\n app()->bind(QueryBuilder::class, function () use ($userToSearch) {\n $queryBuilder = $this->createMock(QueryBuilder::class);\n $queryBuilder->expects($this->once())\n ->method('buildGetUsersQuery')\n ->with($userToSearch)\n ->willReturn('SELECT * FROM Users');\n\n return $queryBuilder;\n });\n\n $queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults($salesforceUsers, count($salesforceUsers), true, null));\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('query')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n }\n\n private function mockTeamRepository(\n Team $team,\n array $salesforceUser,\n ?User $user = null,\n bool $userSearch = true,\n array $customRules = []\n ): void {\n $teamRepository = $this->getMockForAbstractClass(TeamRepository::class, [], '', false, true, true, ['findActiveTeamMemberByEmail', 'getTeamSetting']);\n\n if ($userSearch) {\n $teamRepository->expects($this->once())\n ->method('findActiveTeamMemberByEmail')\n ->with($team, $salesforceUser['Email'])\n ->willReturn($user);\n }\n\n $teamSettings = $this->createMock(TeamSettings::class);\n $teamSettings->method('getValueType')\n ->willReturn('array');\n\n $teamSettings->method('getValue')\n ->willReturn(json_encode($customRules));\n\n $teamRepository->expects($this->once())\n ->method('getTeamSetting')\n ->with($team, 'custom_profile_validation')\n ->willReturn($teamSettings);\n\n app()->bind(TeamRepository::class, function () use ($teamRepository) {\n return $teamRepository;\n });\n }\n\n private function mockProfileRepository(User $user): void\n {\n $profileRepository = $this->createMock(ProfileRepository::class);\n $profileRepository->expects($this->once())\n ->method('updateOrCreateProfile')\n ->with(\n $user,\n [\n 'crm_configuration_id' => 1,\n 'crm_provider_id' => '456',\n ],\n [\n 'user_id' => 123,\n 'edition' => Profile::EDITION_LIGHTNING,\n 'has_external_cti' => true,\n 'crm_profile_id' => '789',\n ]\n )\n ->willReturn(new Profile());\n\n $this->app->instance(ProfileRepository::class, $profileRepository);\n }\n\n public function testBuildEnhancedNoteDecodesHtmlEntities(): void\n {\n $service = $this->getServiceMock(['createRecord']);\n\n $profile = new Profile();\n $profile->setAttribute('crm_provider_id', 'owner-123');\n $service->profile = $profile;\n\n $service->expects($this->exactly(2))\n ->method('createRecord')\n ->willReturnOnConsecutiveCalls('note-id-123', 'link-id-456');\n\n $bodyWithEntities = 'Welch's current challenges and Facebook's Club';\n\n $result = $this->invokePrivateMethod('buildEnhancedNote', $service, [\n 'Test Title',\n $bodyWithEntities,\n 'object-id-789',\n ]);\n\n $this->assertEquals('note-id-123', $result);\n }\n\n public function testBuildEnhancedNoteSanitizesWithoutQuotes(): void\n {\n $service = $this->getServiceMock(['createRecord']);\n\n $profile = new Profile();\n $profile->setAttribute('crm_provider_id', 'owner-456');\n $service->profile = $profile;\n\n $service->expects($this->exactly(2))\n ->method('createRecord')\n ->willReturnCallback(function ($type, $data) {\n if ($type === 'ContentNote') {\n $decoded = base64_decode($data['Content']);\n $this->assertStringContainsString(\"Welch's\", $decoded);\n $this->assertStringNotContainsString(''', $decoded);\n $this->assertStringNotContainsString('&#039;', $decoded);\n $this->assertStringContainsString('<script>', $decoded);\n\n return 'note-id-456';\n }\n\n return 'link-id-789';\n });\n\n $bodyWithMixedContent = \"Welch's and <script>alert('xss')</script>\";\n\n $result = $this->invokePrivateMethod('buildEnhancedNote', $service, [\n 'Test Title',\n $bodyWithMixedContent,\n 'object-id-123',\n ]);\n\n $this->assertEquals('note-id-456', $result);\n }\n\n public function testBuildEnhancedNoteConvertsLineBreaks(): void\n {\n $service = $this->getServiceMock(['createRecord']);\n\n $profile = new Profile();\n $profile->setAttribute('crm_provider_id', 'owner-789');\n $service->profile = $profile;\n\n $service->expects($this->exactly(2))\n ->method('createRecord')\n ->willReturnCallback(function ($type, $data) {\n if ($type === 'ContentNote') {\n $decoded = base64_decode($data['Content']);\n $this->assertStringContainsString('<br>', $decoded);\n $this->assertStringNotContainsString('<br />', $decoded);\n\n return 'note-id-789';\n }\n\n return 'link-id-012';\n });\n\n $bodyWithLineBreaks = \"Line 1\\nLine 2\\nLine 3\";\n\n $result = $this->invokePrivateMethod('buildEnhancedNote', $service, [\n 'Test Title',\n $bodyWithLineBreaks,\n 'object-id-456',\n ]);\n\n $this->assertEquals('note-id-789', $result);\n }\n\n public function testBuildEnhancedNoteHandlesComplexScenario(): void\n {\n $service = $this->getServiceMock(['createRecord']);\n\n $profile = new Profile();\n $profile->setAttribute('crm_provider_id', 'owner-complex');\n $service->profile = $profile;\n\n $service->expects($this->exactly(2))\n ->method('createRecord')\n ->willReturnCallback(function ($type, $data) {\n if ($type === 'ContentNote') {\n $decoded = base64_decode($data['Content']);\n\n $this->assertStringContainsString(\"Welch's\", $decoded);\n $this->assertStringContainsString(\"Facebook's Club\", $decoded);\n $this->assertStringContainsString(\"Arctics'\", $decoded);\n $this->assertStringNotContainsString(''', $decoded);\n $this->assertStringNotContainsString('&#039;', $decoded);\n $this->assertStringContainsString('<br>', $decoded);\n $this->assertStringContainsString('<', $decoded);\n $this->assertStringContainsString('>', $decoded);\n\n return 'note-complex';\n }\n\n return 'link-complex';\n });\n\n $complexBody = \"Summary:\\n---------\\nThe call focused on understanding Welch's current challenges and exploring how Arctics' Revenue Growth Management solutions could support their strategic goals.\\n\\n• John SMith discussed his role as a category advisor for Google and Facebook's Club, emphasizing the importance of market research and advising on product assortment.\\n• Madona introduced Arctics' Virtual Shoppers AI, which simulates consumer <behavior> to optimize pricing and promotional strategies.\";\n\n $result = $this->invokePrivateMethod('buildEnhancedNote', $service, [\n 'Jiminny Transcription Summary',\n $complexBody,\n 'task-id-001',\n ]);\n\n $this->assertEquals('note-complex', $result);\n }\n\n public function testSyncRemotelyDeletedObjectsWithErrorHandlingSuccess(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['syncRemotelyDeletedObjects'])\n ->getMock();\n\n // Mock team\n $team = $this->createMock(Team::class);\n $team->method('getUuid')->willReturn('team-uuid-123');\n $serviceMock->team = $team;\n\n // Expect syncRemotelyDeletedObjects to be called once and succeed\n $serviceMock->expects($this->once())\n ->method('syncRemotelyDeletedObjects')\n ->with(\\Jiminny\\Enums\\CrmObject::ACCOUNT);\n\n // Call the protected method using reflection\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');\n $method->setAccessible(true);\n\n // Should not throw any exceptions\n $method->invoke($serviceMock, \\Jiminny\\Enums\\CrmObject::ACCOUNT);\n\n $this->assertTrue(true); // Test completed successfully\n }\n\n public function testSyncRemotelyDeletedObjectsWithErrorHandlingFailure(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['syncRemotelyDeletedObjects'])\n ->getMock();\n\n // Mock team\n $team = $this->createMock(Team::class);\n $team->method('getUuid')->willReturn('team-uuid-456');\n $serviceMock->team = $team;\n\n // Mock logger to verify warning is logged\n $logger = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n\n // Use reflection to set the protected logger property\n $reflection = new \\ReflectionClass($serviceMock);\n $loggerProperty = $reflection->getProperty('logger');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($serviceMock, $logger);\n\n $exception = new \\Exception('Sync failed due to API error');\n\n // Expect syncRemotelyDeletedObjects to throw an exception\n $serviceMock->expects($this->once())\n ->method('syncRemotelyDeletedObjects')\n ->with(\\Jiminny\\Enums\\CrmObject::CONTACT)\n ->willThrowException($exception);\n\n // Expect warning to be logged with correct message and parameters\n $logger->expects($this->once())\n ->method('warning')\n ->with(\n '[Salesforce] Remotely deleted objects sync failed',\n [\n 'objectType' => 'contact',\n 'teamId' => 'team-uuid-456',\n 'reason' => 'Sync failed due to API error',\n ]\n );\n\n // Call the protected method using reflection\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');\n $method->setAccessible(true);\n\n // Should not re-throw the exception, just log it\n $method->invoke($serviceMock, \\Jiminny\\Enums\\CrmObject::CONTACT);\n\n $this->assertTrue(true); // Test completed successfully\n }\n\n public function testSyncRemotelyDeletedObjectsWithErrorHandlingWithLogParams(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['syncRemotelyDeletedObjects'])\n ->getMock();\n\n // Mock team\n $team = $this->createMock(Team::class);\n $team->method('getUuid')->willReturn('team-uuid-789');\n $serviceMock->team = $team;\n\n // Mock logger to verify warning is logged\n $logger = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n\n // Use reflection to set the protected logger property\n $loggerReflection = new \\ReflectionClass($serviceMock);\n $loggerProperty = $loggerReflection->getProperty('logger');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($serviceMock, $logger);\n\n $exception = new \\Exception('Network timeout');\n\n // Expect syncRemotelyDeletedObjects to throw an exception\n $serviceMock->expects($this->once())\n ->method('syncRemotelyDeletedObjects')\n ->with(\\Jiminny\\Enums\\CrmObject::OPPORTUNITY)\n ->willThrowException($exception);\n\n // Additional log parameters\n $logParams = [\n 'syncType' => 'full',\n 'batchSize' => 100,\n ];\n\n // Expect warning to be logged with merged parameters\n $logger->expects($this->once())\n ->method('warning')\n ->with(\n '[Salesforce] Remotely deleted objects sync failed',\n [\n 'objectType' => 'opportunity',\n 'teamId' => 'team-uuid-789',\n 'reason' => 'Network timeout',\n 'syncType' => 'full',\n 'batchSize' => 100,\n ]\n );\n\n // Call the protected method using reflection\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');\n $method->setAccessible(true);\n\n // Should not re-throw the exception, just log it\n $method->invoke($serviceMock, \\Jiminny\\Enums\\CrmObject::OPPORTUNITY, $logParams);\n\n $this->assertTrue(true); // Test completed successfully\n }\n\n /**\n * @dataProvider crmObjectProvider\n */\n public function testSyncRemotelyDeletedObjectsWithErrorHandlingDifferentCrmObjects(\\Jiminny\\Enums\\CrmObject $crmObject): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['syncRemotelyDeletedObjects'])\n ->getMock();\n\n // Mock team\n $team = $this->createMock(Team::class);\n $team->method('getUuid')->willReturn('team-uuid-test');\n $serviceMock->team = $team;\n\n // Mock logger to verify warning is logged\n $logger = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n\n // Use reflection to set the protected logger property\n $loggerReflectionClass = new \\ReflectionClass($serviceMock);\n $loggerProperty = $loggerReflectionClass->getProperty('logger');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($serviceMock, $logger);\n\n $exception = new \\Exception('Test error');\n\n // Expect syncRemotelyDeletedObjects to throw an exception\n $serviceMock->expects($this->once())\n ->method('syncRemotelyDeletedObjects')\n ->with($crmObject)\n ->willThrowException($exception);\n\n // Expect warning to be logged with correct entity type\n $logger->expects($this->once())\n ->method('warning')\n ->with(\n '[Salesforce] Remotely deleted objects sync failed',\n [\n 'objectType' => $crmObject->value,\n 'teamId' => 'team-uuid-test',\n 'reason' => 'Test error',\n ]\n );\n\n // Call the protected method using reflection\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');\n $method->setAccessible(true);\n\n $method->invoke($serviceMock, $crmObject);\n\n $this->assertTrue(true); // Test completed successfully\n }\n\n public function testHandleObjectDeletionWithDeletedEntity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['deleteCrmObject'])\n ->getMock();\n\n $entity = $this->createMock(\\Jiminny\\Models\\Account::class);\n $crmData = ['IsDeleted' => true];\n\n $serviceMock->expects($this->once())\n ->method('deleteCrmObject')\n ->with($entity);\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('handleObjectDeletion');\n $method->setAccessible(true);\n\n $method->invoke($serviceMock, $entity, $crmData);\n }\n\n public function testHandleObjectDeletionWithNonDeletedEntity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['deleteCrmObject'])\n ->getMock();\n\n $entity = $this->createMock(\\Jiminny\\Models\\Contact::class);\n $crmData = ['IsDeleted' => false];\n\n $serviceMock->expects($this->never())\n ->method('deleteCrmObject');\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('handleObjectDeletion');\n $method->setAccessible(true);\n\n $method->invoke($serviceMock, $entity, $crmData);\n }\n\n public function testDeleteCrmObjectWithValidEntity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['dispatchDeleteCrmObjectJob'])\n ->getMock();\n\n $entity = $this->createMock(\\Jiminny\\Models\\Lead::class);\n $entity->expects($this->once())->method('delete');\n\n $serviceMock->expects($this->once())\n ->method('dispatchDeleteCrmObjectJob')\n ->with($entity);\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('deleteCrmObject');\n $method->setAccessible(true);\n\n $method->invoke($serviceMock, $entity);\n }\n\n public function testDeleteCrmObjectWithNullEntity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['dispatchDeleteCrmObjectJob'])\n ->getMock();\n\n $serviceMock->expects($this->never())\n ->method('dispatchDeleteCrmObjectJob');\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('deleteCrmObject');\n $method->setAccessible(true);\n\n $method->invoke($serviceMock, null);\n }\n\n public function testDispatchDeleteCrmObjectJobWithNullEntity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($service);\n $method = $reflection->getMethod('dispatchDeleteCrmObjectJob');\n $method->setAccessible(true);\n\n // Should return early without dispatching - no exception expected\n $method->invoke($service, null);\n\n $this->assertTrue(true); // Test completed successfully\n }\n\n public function testDispatchDeleteCrmObjectJobWithUnsupportedEntity(): void\n {\n $this->expectException(\\TypeError::class);\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n $unsupportedEntity = $this->createMock(\\stdClass::class);\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($service);\n $method = $reflection->getMethod('dispatchDeleteCrmObjectJob');\n\n // This will throw TypeError due to union type constraint\n $method->invoke($service, $unsupportedEntity);\n }\n\n public function testHandleEntityDeletionByProviderIdMethodExists(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n // Test that the method exists and is accessible via reflection\n $reflection = new \\ReflectionClass($service);\n $method = $reflection->getMethod('handleEntityDeletionByProviderId');\n $method->setAccessible(true);\n\n // Verify method exists and has correct parameters\n $this->assertTrue($method->isProtected());\n $this->assertEquals(2, $method->getNumberOfParameters());\n\n $parameters = $method->getParameters();\n $this->assertEquals('targetEntity', $parameters[0]->getName());\n $this->assertEquals('crmData', $parameters[1]->getName());\n }\n\n public function testSyncRemotelyDeletedObjectsWithNoResults(): void\n {\n // Create a real service instance to avoid mock property issues\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n // Mock queryHandler to throw NoResultsException\n $queryHandler = $this->createMock(QueryHandler::class);\n $queryHandler->expects($this->once())\n ->method('queryDeleted')\n ->with('Opportunity')\n ->willThrowException(new NoResultsException('No results'));\n\n // Set the queryHandler using reflection on the real service\n $reflection = new \\ReflectionClass($service);\n $queryHandlerProperty = $reflection->getProperty('queryHandler');\n $queryHandlerProperty->setAccessible(true);\n $queryHandlerProperty->setValue($service, $queryHandler);\n\n $result = self::invokePrivateMethod('syncRemotelyDeletedObjects', $service, [CrmObject::OPPORTUNITY]);\n\n $this->assertFalse($result);\n }\n\n public function testSyncRemotelyDeletedObjectsWithEmptyResults(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n // Mock queryHandler to return empty results\n $queryResult = $this->createMock(QueryResults::class);\n $queryResult->method('getResults')->willReturn([]);\n\n $queryHandler = $this->createMock(QueryHandler::class);\n $queryHandler->expects($this->once())\n ->method('queryDeleted')\n ->with('Opportunity')\n ->willReturn($queryResult);\n\n // Set the queryHandler using reflection on the real service\n $reflection = new \\ReflectionClass($service);\n $queryHandlerProperty = $reflection->getProperty('queryHandler');\n $queryHandlerProperty->setAccessible(true);\n $queryHandlerProperty->setValue($service, $queryHandler);\n\n $result = self::invokePrivateMethod('syncRemotelyDeletedObjects', $service, [CrmObject::OPPORTUNITY]);\n\n $this->assertFalse($result);\n }\n\n public function testSyncRemotelyDeletedObjectsWithUnsupportedCrmObject(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n // Mock queryHandler to return some deleted objects so we reach the match statement\n $deletedObjects = [\n ['id' => 'task1'],\n ['id' => 'task2'],\n ];\n $queryResult = $this->createMock(QueryResults::class);\n $queryResult->method('getResults')->willReturn($deletedObjects);\n\n $queryHandler = $this->createMock(QueryHandler::class);\n $queryHandler->expects($this->once())\n ->method('queryDeleted')\n ->with('Task') // ucfirst('task') = 'Task'\n ->willReturn($queryResult);\n\n self::setPrivateProperty($service, 'queryHandler', $queryHandler);\n\n $this->expectException(InvalidArgumentException::class);\n $this->expectExceptionMessage('Unsupported CrmObject: task');\n\n self::invokePrivateMethod('syncRemotelyDeletedObjects', $service, [CrmObject::TASK]);\n }\n\n public static function crmObjectProvider(): array\n {\n return [\n 'Account' => [CrmObject::ACCOUNT],\n 'Contact' => [CrmObject::CONTACT],\n 'Lead' => [CrmObject::LEAD],\n 'Opportunity' => [CrmObject::OPPORTUNITY],\n ];\n }\n\n public function testVerifyTaskExistsReturnsTrue(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-123', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-123');\n $activity->method('getId')->willReturn(456);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-123', ['Id', 'IsDeleted'])\n ->willReturn(['Id' => 'task-123', 'IsDeleted' => false]);\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertTrue($result);\n }\n\n public function testVerifyTaskExistsReturnsTrueForEvent(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:event-123', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('event-123');\n $activity->method('getId')->willReturn(456);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_EVENT);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Event', 'event-123', ['Id', 'IsDeleted'])\n ->willReturn(['Id' => 'event-123', 'IsDeleted' => false]);\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertTrue($result);\n }\n\n public function testVerifyTaskExistsReturnsFalseWhenDeleted(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-456', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-456');\n $activity->method('getId')->willReturn(789);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-456', ['Id', 'IsDeleted'])\n ->willReturn(['Id' => 'task-456', 'IsDeleted' => true]);\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertFalse($result);\n }\n\n public function testVerifyTaskExistsReturnsFalseWhenNotFound(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-999', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-999');\n $activity->method('getId')->willReturn(999);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-999', ['Id', 'IsDeleted'])\n ->willThrowException(new \\Jiminny\\Exceptions\\HttpNotFoundException('Task not found'));\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertFalse($result);\n }\n\n public function testVerifyTaskExistsReturnsFalseWhenNoPlaybook(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-no-playbook', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-no-playbook');\n $activity->method('getId')->willReturn(111);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn(null);\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertFalse($result);\n }\n\n public function testVerifyTaskExistsThrowsExceptionForTransientErrors(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-error', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-error');\n $activity->method('getId')->willReturn(888);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-error', ['Id', 'IsDeleted'])\n ->willThrowException(new \\RuntimeException('Network timeout'));\n\n $this->expectException(\\RuntimeException::class);\n $this->expectExceptionMessage('Network timeout');\n\n $service->verifyTaskExists($activity);\n }\n\n public function testVerifyTaskExistsCachesResults(): void\n {\n $cachedValue = null;\n Cache::shouldReceive('remember')\n ->twice()\n ->with('crm_task_exists:123:task-cached', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(function ($key, $ttl, $callback) use (&$cachedValue) {\n if ($cachedValue === null) {\n $cachedValue = $callback();\n }\n\n return $cachedValue;\n });\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-cached');\n $activity->method('getId')->willReturn(555);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-cached', ['Id', 'IsDeleted'])\n ->willReturn(['Id' => 'task-cached', 'IsDeleted' => false]);\n\n $result1 = $service->verifyTaskExists($activity);\n $result2 = $service->verifyTaskExists($activity);\n\n $this->assertTrue($result1);\n $this->assertTrue($result2);\n }\n\n public function testVerifyTaskExistsReturnsFalseForHttpBadRequestException(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-400', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-400');\n $activity->method('getId')->willReturn(400);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-400', ['Id', 'IsDeleted'])\n ->willThrowException(new \\Jiminny\\Exceptions\\HttpBadRequestException('Bad request'));\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertFalse($result);\n }\n\n public function testImportOpportunitySkipsWhenNoProfileAndNoAccount(): void\n {\n $crmData = [\n 'Id' => 'SF-NO-USER-1',\n 'Name' => 'Test Opportunity',\n 'OwnerId' => 'owner-no-profile',\n // No AccountId\n ];\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(\\Illuminate\\Events\\Dispatcher::class); // ← ADD THIS\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n\n $service = new Service(\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService\n );\n\n $config = $this->createMock(Configuration::class);\n\n // Mock profiles relation returning null (no profile found)\n $profilesRelation = \\Mockery::mock(\\Illuminate\\Database\\Eloquent\\Relations\\HasMany::class);\n $profilesRelation->shouldReceive('where')->with('crm_provider_id', 'owner-no-profile')->andReturnSelf();\n $profilesRelation->shouldReceive('first')->andReturn(null);\n\n $config->expects($this->once())\n ->method('profiles')\n ->willReturn($profilesRelation);\n\n $team = $this->createMock(Team::class);\n $team->method('getId')->willReturn(1);\n\n $logger = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n $logger->expects($this->once())\n ->method('error')\n ->with(\n '[Salesforce] | Skip import, no user_id found',\n ['id' => 'SF-NO-USER-1']\n );\n\n $reflection = new \\ReflectionClass($service);\n\n $configProperty = $reflection->getProperty('config');\n $configProperty->setAccessible(true);\n $configProperty->setValue($service, $config);\n\n $teamProperty = $reflection->getProperty('team');\n $teamProperty->setAccessible(true);\n $teamProperty->setValue($service, $team);\n\n $loggerProperty = $reflection->getProperty('logger');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($service, $logger);\n\n // Initialize profile property to avoid \"must not be accessed before initialization\" error\n $profileProperty = $reflection->getProperty('profile');\n $profileProperty->setAccessible(true);\n $profileProperty->setValue($service, null);\n\n $result = self::invokePrivateMethod('importOpportunity', $service, [$crmData]);\n\n $this->assertNull($result);\n }\n\n public function testImportOpportunitySkipsWhenProfileNotFound(): void\n {\n $crmData = [\n 'Id' => 'SF-NO-USER-2',\n 'Name' => 'Test Opportunity',\n 'OwnerId' => 'owner-not-found',\n // No AccountId - avoid complex account processing\n ];\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $eventDispatcher = $this->createMock(\\Illuminate\\Events\\Dispatcher::class); // ← ADD THIS\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n\n\n $service = new Service(\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService\n );\n\n $config = $this->createMock(Configuration::class);\n\n // Mock profiles relation returning null (no profile found)\n $profilesRelation = \\Mockery::mock(\\Illuminate\\Database\\Eloquent\\Relations\\HasMany::class);\n $profilesRelation->shouldReceive('where')->with('crm_provider_id', 'owner-not-found')->andReturnSelf();\n $profilesRelation->shouldReceive('first')->andReturn(null);\n\n $config->expects($this->once())\n ->method('profiles')\n ->willReturn($profilesRelation);\n\n $team = $this->createMock(Team::class);\n $team->method('getId')->willReturn(1);\n\n $logger = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n $logger->expects($this->once())\n ->method('error')\n ->with(\n '[Salesforce] | Skip import, no user_id found',\n ['id' => 'SF-NO-USER-2']\n );\n\n $reflection = new \\ReflectionClass($service);\n\n $configProperty = $reflection->getProperty('config');\n $configProperty->setAccessible(true);\n $configProperty->setValue($service, $config);\n\n $teamProperty = $reflection->getProperty('team');\n $teamProperty->setAccessible(true);\n $teamProperty->setValue($service, $team);\n\n $loggerProperty = $reflection->getProperty('logger');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($service, $logger);\n\n // Initialize profile property\n $profileProperty = $reflection->getProperty('profile');\n $profileProperty->setAccessible(true);\n $profileProperty->setValue($service, null);\n\n $result = self::invokePrivateMethod('importOpportunity', $service, [$crmData]);\n\n $this->assertNull($result);\n }\n}\n\nclass HasManyExtended extends HasMany\n{\n public function where()\n {\n }\n\n public function first()\n {\n }\n}","depth":4,"bounds":{"left":0.16223404,"top":0.09736632,"width":0.3550532,"height":0.90263367},"on_screen":true,"value":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Services\\Crm\\Salesforce;\n\nuse Illuminate\\Database\\Eloquent\\Relations\\HasMany;\nuse Illuminate\\Events\\Dispatcher;\nuse Illuminate\\Support\\Facades\\Cache;\nuse Jiminny\\Component\\Country\\CountriesMap;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Enums\\CrmObject;\nuse Jiminny\\Exceptions\\InvalidArgumentException;\nuse Jiminny\\Exceptions\\NoResultsException;\nuse Jiminny\\Integrations\\PlaybookResolver;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Crm\\Configuration;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\TeamSettings;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\ContactRoleRepository;\nuse Jiminny\\Repositories\\Crm\\FieldDataRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\Salesforce\\Client;\nuse Jiminny\\Services\\Crm\\Salesforce\\PayloadBuilder;\nuse Jiminny\\Services\\Crm\\Salesforce\\QueryBuilder;\nuse Jiminny\\Services\\Crm\\Salesforce\\QueryHandler;\nuse Jiminny\\Services\\Crm\\Salesforce\\QueryIterator;\nuse Jiminny\\Services\\Crm\\Salesforce\\QueryResults;\nuse Jiminny\\Services\\Crm\\Salesforce\\Service;\nuse PHPUnit\\Framework\\MockObject\\MockObject;\nuse Tests\\TestCase;\nuse Tests\\Unit\\Traits\\TestPrivateMethod;\n\nclass ServiceTest extends TestCase\n{\n use TestPrivateMethod;\n\n public function testFetchAndAssociateRelatedActivity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $payloadBuilder->method('addCustomLogicFieldsPayload')\n ->willReturnCallback(function ($activity, $payload) {\n return $payload;\n });\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods(['fetchRelatedActivity', 'getPlaybook', 'getPlaybookCategory', 'updateRecord'])\n ->getMock();\n\n $serviceMock->expects($this->once())\n ->method('fetchRelatedActivity')\n ->willReturn([\n 'Id' => 'testId',\n 'Type' => null,\n 'OwnerId' => 'testerUser',\n 'Description' => 'Test description',\n ]);\n\n $user = $this->createMock(User::class);\n $team = $this->createMock(Team::class);\n $user->method('getAttribute')->with('team')->willReturn($team);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityField')->willReturn(null);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_EVENT);\n\n $serviceMock->expects($this->once())\n ->method('getPlaybook')\n ->with($user)\n ->willReturn($playbook);\n\n $serviceMock->expects($this->never())\n ->method('getPlaybookCategory');\n\n $serviceMock->expects($this->never())\n ->method('updateRecord');\n\n $fieldDataRepository = $this->createMock(FieldDataRepository::class);\n $fieldDataRepository->method('getActivityFieldData')->willReturn(collect([]));\n app()->instance(FieldDataRepository::class, $fieldDataRepository);\n\n $config = $this->createMock(Configuration::class);\n $profilesRelation = $this->getMockBuilder(\\Illuminate\\Database\\Eloquent\\Relations\\HasMany::class)\n ->disableOriginalConstructor()\n ->onlyMethods(['get'])\n ->addMethods(['where'])\n ->getMock();\n $profilesRelation->method('where')->willReturnSelf();\n $profilesRelation->method('get')->willReturn(collect([]));\n $config->method('profiles')->willReturn($profilesRelation);\n\n $serviceMock->config = $config;\n $serviceMock->profile = null;\n\n $actualStartTime = \\Carbon\\Carbon::now();\n\n $activity = $this->getMockBuilder(Activity::class)\n ->disableOriginalConstructor()\n ->onlyMethods(['update', 'hasProspect'])\n ->getMock();\n\n $activity->method('update')->willReturn(true);\n $activity->method('hasProspect')->willReturn(true);\n\n $activity->type = Activity::TYPE_CONFERENCE;\n $activity->provider = Activity::PROVIDER_TWILIO;\n $activity->lead_id = 1;\n $activity->user_id = 0;\n $activity->id_string = 'test-activity-id';\n $activity->user = $user;\n\n $activity->actual_start_time = $actualStartTime;\n $activity->uuid = 'c53d8320-f556-4cee-a2f8-5f232f454ca4';\n\n app()->bind(PlaybookResolver::class, function () use ($user) {\n $playbook = $this->createMock(Playbook::class);\n $playbookResolver = $this->createMock(PlaybookResolver::class);\n $playbookResolver->expects($this->once())\n ->method('resolvePlaybookByUser')\n ->with($user)\n ->willReturn($playbook);\n\n return $playbookResolver;\n });\n\n $data = $serviceMock->fetchAndAssociateRelatedActivity($activity);\n\n $this->assertInstanceOf(Activity::class, $data);\n $this->assertEquals(Activity::TYPE_CONFERENCE, $data->getType());\n $this->assertEquals($actualStartTime->getTimestamp(), $data->getActualStartTime()->getTimestamp());\n }\n\n public function testFetchAndAssociateRelatedActivitySkipsForTaskBasedPlaybook(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods(['fetchRelatedActivity', 'getPlaybook'])\n ->getMock();\n\n $user = $this->createMock(User::class);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n $playbook->method('getId')->willReturn(123);\n\n $serviceMock->expects($this->once())\n ->method('getPlaybook')\n ->with($user)\n ->willReturn($playbook);\n\n $serviceMock->expects($this->never())\n ->method('fetchRelatedActivity');\n\n $activity = $this->getMockBuilder(Activity::class)\n ->disableOriginalConstructor()\n ->onlyMethods(['hasProspect', 'getUuid'])\n ->getMock();\n\n $activity->method('hasProspect')->willReturn(true);\n $activity->method('getUuid')->willReturn('c53d8320-f556-4cee-a2f8-5f232f454ca4');\n $activity->type = Activity::TYPE_CONFERENCE;\n $activity->actual_start_time = \\Carbon\\Carbon::now();\n $activity->user = $user;\n\n $result = $serviceMock->fetchAndAssociateRelatedActivity($activity);\n\n $this->assertNull($result);\n }\n\n public function testFetchAndAssociateRelatedActivityReturnsNullForNonConference(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $serviceMock = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)\n );\n\n $activity = $this->getMockBuilder(Activity::class)\n ->disableOriginalConstructor()\n ->getMock();\n\n $activity->type = Activity::TYPE_SOFTPHONE;\n\n $result = $serviceMock->fetchAndAssociateRelatedActivity($activity);\n\n $this->assertNull($result);\n }\n\n public function testFetchAndAssociateRelatedActivityReturnsNullWhenNoStartTime(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $serviceMock = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)\n );\n\n\n $activity = $this->getMockBuilder(Activity::class)\n ->disableOriginalConstructor()\n ->getMock();\n\n $activity->type = Activity::TYPE_CONFERENCE;\n $activity->actual_start_time = null;\n $activity->scheduled_start_time = null;\n\n $result = $serviceMock->fetchAndAssociateRelatedActivity($activity);\n\n $this->assertNull($result);\n }\n\n public function testFetchAndAssociateRelatedActivityReturnsNullWhenNoProspect(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods(['getPlaybook'])\n ->getMock();\n\n $serviceMock->expects($this->never())\n ->method('getPlaybook');\n\n $activity = $this->getMockBuilder(Activity::class)\n ->disableOriginalConstructor()\n ->onlyMethods(['hasProspect', 'getUuid'])\n ->getMock();\n\n $activity->method('hasProspect')->willReturn(false);\n $activity->method('getUuid')->willReturn('c53d8320-f556-4cee-a2f8-5f232f454ca4');\n $activity->type = Activity::TYPE_CONFERENCE;\n $activity->actual_start_time = \\Carbon\\Carbon::now();\n\n $result = $serviceMock->fetchAndAssociateRelatedActivity($activity);\n\n $this->assertNull($result);\n }\n\n public function testMatchExactlyByEmail(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods([])\n ->getMock();\n\n $profile = new Profile();\n $profile->setAttribute('id', bin2hex(random_bytes(8)));\n $serviceMock->profile = $profile;\n\n $team = $this->createMock(Team::class);\n $serviceMock->team = $team;\n\n $data = $serviceMock->matchExactlyByEmail(bin2hex(random_bytes(8)) . 'test_email@testserver.com');\n\n $this->assertEquals(null, $data);\n }\n\n public function testMatchDomainFromEmail(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $queryIterator = $this->createMock(QueryIterator::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $config = $this->createMock(Configuration::class);\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('search')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods(['convertCrmData'])\n ->getMock();\n\n $profile = new Profile();\n $profile->account_fields = 'Field1, Field2, Field3';\n $serviceMock->profile = $profile;\n\n $serviceMock->expects($this->once())\n ->method('convertCrmData')\n ->willReturn(['test']);\n\n $this->app->bind(QueryBuilder::class, function () {\n $queryBuilder = $this->createMock(QueryBuilder::class);\n $queryBuilder->expects($this->once())\n ->method('buildMatchByDomainQuery')\n ->with('test_email@testserver.com')\n ->willReturn('FIND {test_email@testserver.com} IN ALL FIELDS RETURNING Account(Id)');\n\n return $queryBuilder;\n });\n\n $team = $this->createMock(Team::class);\n\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n\n $data = $serviceMock->matchByDomain('test_email@testserver.com');\n\n $this->assertEquals(['test'], $data);\n }\n\n public function testBuildTaskSearchFields(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)\n );\n\n $fields = $service->buildTaskSearchFields();\n\n $expectedFields = ['Id', 'WhoId', 'WhatId', 'AccountId'];\n\n $this->assertEquals($expectedFields, $fields);\n }\n\n public function testMapCrmObjects(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class),\n );\n\n $sampleTask = [\n 'WhoId' => '003sampleWhoId',\n 'AccountId' => 'sampleAccountId',\n 'WhatId' => 'sampleWhatId',\n ];\n\n $activityData = $service->mapCrmObjects($sampleTask);\n\n $expectedActivityData = [\n 'contact' => '003sampleWhoId',\n 'account' => 'sampleAccountId',\n 'opportunity' => 'sampleWhatId',\n ];\n\n $this->assertEquals($expectedActivityData, $activityData);\n }\n\n public function testGetInstalledAppVersion(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n $queryIterator = $this->createMock(QueryIterator::class);\n $queryIterator->expects($this->any())\n ->method('current')->willReturn([\n 'SubscriberPackageVersion' => [\n 'MajorVersion' => '1',\n 'MinorVersion' => '0',\n 'PatchVersion' => '1',\n 'BuildNumber' => '0',\n ],\n ]);\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('metadata')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods(array_diff(get_class_methods(Service::class), ['getInstalledAppVersion']))\n ->getMock();\n\n $version = $serviceMock->getInstalledAppVersion();\n\n $this->assertEquals('1010', $version);\n }\n\n public function testSyncProfiles(): void\n {\n $userToSearch = null;\n\n $team = $this->createMock(Team::class);\n $config = $this->createMockedConfiguration();\n $config->expects($this->once())\n ->method('getId')\n ->willReturn(1);\n\n $salesforceUser = [\n 'Email' => 'test@example.com',\n 'UserPreferencesLightningExperiencePreferred' => true,\n 'CallCenterId' => '123',\n 'Id' => '456',\n 'ProfileId' => '789',\n ];\n\n app()->bind(QueryBuilder::class, function () use ($userToSearch) {\n $queryBuilder = $this->createMock(QueryBuilder::class);\n $queryBuilder->expects($this->once())\n ->method('buildGetUsersQuery')\n ->with($userToSearch)\n ->willReturn('SELECT * FROM Users');\n\n return $queryBuilder;\n });\n\n $queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('query')\n ->with('SELECT * FROM Users')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n\n $user = $this->createMock(User::class);\n $user->expects($this->once())\n ->method('getId')\n ->willReturn(123);\n\n $this->mockTeamRepository($team, $salesforceUser, $user);\n\n $profileRepository = $this->createMock(ProfileRepository::class);\n $profileRepository->expects($this->once())\n ->method('updateOrCreateProfile')\n ->with(\n $user,\n [\n 'crm_configuration_id' => 1,\n 'crm_provider_id' => '456',\n ],\n [\n 'user_id' => 123,\n 'edition' => Profile::EDITION_LIGHTNING,\n 'has_external_cti' => true,\n 'crm_profile_id' => '789',\n ]\n )\n ->willReturn(new Profile());\n\n $this->app->instance(ProfileRepository::class, $profileRepository);\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n $result = $serviceMock->syncProfiles($userToSearch);\n\n $this->assertNull($result);\n }\n\n public function testSyncProfilesEmailIsNull(): void\n {\n $userToSearch = $this->createMock(User::class);\n\n $salesforceUser = [\n 'Email' => null,\n ];\n\n app()->bind(QueryBuilder::class, function () {\n $queryBuilder = $this->createMock(QueryBuilder::class);\n $queryBuilder->expects($this->once())\n ->method('buildGetUsersQuery')\n ->with(null)\n ->willReturn('SELECT * FROM Users');\n\n return $queryBuilder;\n });\n\n $queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->exactly(2))\n ->method('query')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n\n $team = $this->createMock(Team::class);\n $user = $this->createMock(User::class);\n $this->mockTeamRepository($team, $salesforceUser, $user, false);\n\n $config = $this->createMock(Configuration::class);\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n\n $profile = $serviceMock->syncProfiles(null);\n\n $this->assertNull($profile);\n }\n\n public function testSyncProfilesUserToSearchMatchesCurrentUser(): void\n {\n $userToSearch = $this->createMock(User::class);\n $userToSearch->expects($this->once())\n ->method('getId')\n ->willReturn(123);\n\n $team = $this->createMock(Team::class);\n $config = $this->createMock(Configuration::class);\n $config->expects($this->once())\n ->method('getId')\n ->willReturn(1);\n\n $salesforceUser = [\n 'Email' => 'test@example.com',\n 'UserPreferencesLightningExperiencePreferred' => true,\n 'CallCenterId' => '123',\n 'Id' => '456',\n 'ProfileId' => '789',\n ];\n\n app()->bind(QueryBuilder::class, function () use ($userToSearch) {\n $queryBuilder = $this->createMock(QueryBuilder::class);\n $queryBuilder->expects($this->once())\n ->method('buildGetUsersQuery')\n ->with($userToSearch)\n ->willReturn('SELECT * FROM Users');\n\n return $queryBuilder;\n });\n\n $queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('query')\n ->with('SELECT * FROM Users')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n\n $user = $this->createMock(User::class);\n $user->expects($this->exactly(2))\n ->method('getId')\n ->willReturn(123);\n\n $this->mockTeamRepository($team, $salesforceUser, $user);\n\n $profileRepository = $this->createMock(ProfileRepository::class);\n $profileRepository->expects($this->once())\n ->method('updateOrCreateProfile')\n ->with(\n $user,\n [\n 'crm_configuration_id' => 1,\n 'crm_provider_id' => '456',\n ],\n [\n 'user_id' => 123,\n 'edition' => Profile::EDITION_LIGHTNING,\n 'has_external_cti' => true,\n 'crm_profile_id' => '789',\n ]\n )\n ->willReturn(new Profile());\n\n $this->app->instance(ProfileRepository::class, $profileRepository);\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n $profile = $serviceMock->syncProfiles($userToSearch);\n\n $this->assertInstanceOf(Profile::class, $profile);\n }\n\n public function testSyncProfilesWithCustomValidation(): void\n {\n $userToSearch = null;\n\n $team = $this->createMock(Team::class);\n $config = $this->createMockedConfiguration();\n $config->expects($this->atLeastOnce()) // Changed from once() to atLeastOnce()\n ->method('getId')\n ->willReturn(1);\n\n $salesforceUser = [\n 'Email' => 'test@example.com',\n 'UserPreferencesLightningExperiencePreferred' => true,\n 'CallCenterId' => '123',\n 'Id' => '456',\n 'ProfileId' => '789',\n 'CustomField' => 'CustomValue',\n ];\n\n $customRules = [\n ['field' => 'CustomField', 'value' => 'CustomValue'],\n ];\n\n $this->mockQueryBuilderAndHandler($userToSearch, [$salesforceUser]);\n\n $user = $this->createMock(User::class);\n $user->expects($this->once())\n ->method('getId')\n ->willReturn(123);\n\n $this->mockTeamRepository($team, $salesforceUser, $user, true, $customRules);\n\n $this->mockProfileRepository($user);\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n $profile = $serviceMock->syncProfiles($userToSearch);\n\n $this->assertNull($profile);\n }\n\n public function testSyncProfilesWithCustomValidationFailing(): void\n {\n $userToSearch = null;\n\n $team = $this->createMock(Team::class);\n $config = $this->createMockedConfiguration();\n\n $salesforceUser = [\n 'Email' => 'test@example.com',\n 'UserPreferencesLightningExperiencePreferred' => true,\n 'CallCenterId' => '123',\n 'Id' => '456',\n 'ProfileId' => '789',\n 'CustomField' => 'WrongValue',\n ];\n\n $customRules = [\n ['field' => 'CustomField', 'value' => 'CustomValue'],\n ];\n\n $this->mockQueryBuilderAndHandler($userToSearch, [$salesforceUser]);\n\n $teamRepository = $this->getMockForAbstractClass(TeamRepository::class, [], '', false, true, true, ['findActiveTeamMemberByEmail', 'getTeamSetting']);\n\n $teamSettings = $this->createMock(TeamSettings::class);\n $teamSettings->method('getValueType')\n ->willReturn('array');\n\n $teamSettings->method('getValue')\n ->willReturn(json_encode($customRules));\n\n $teamRepository->expects($this->once())\n ->method('getTeamSetting')\n ->with($team, 'custom_profile_validation')\n ->willReturn($teamSettings);\n\n app()->bind(TeamRepository::class, function () use ($teamRepository) {\n return $teamRepository;\n });\n\n $profileRepository = $this->createMock(ProfileRepository::class);\n $profileRepository->expects($this->never())\n ->method('updateOrCreateProfile');\n\n $this->app->instance(ProfileRepository::class, $profileRepository);\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $team;\n $serviceMock->config = $config;\n $result = $serviceMock->syncProfiles($userToSearch);\n\n $this->assertNull($result);\n }\n\n public function testGetContactRolesFromCrm(): void\n {\n $contactRoles = [\n [\n 'Id' => '1',\n 'ContactId' => 'Contact1',\n 'OpportunityId' => 'Opportunity1',\n 'Opportunity' => ['OwnerId' => 'Owner1'],\n 'IsPrimary' => true,\n 'Role' => 'Decision Maker',\n ],\n ];\n\n $expectedResponse = [\n [\n 'id' => '1',\n 'contactId' => 'Contact1',\n 'opportunityId' => 'Opportunity1',\n 'ownerId' => 'Owner1',\n 'isPrimary' => true,\n 'role' => 'Decision Maker',\n ],\n ];\n\n $this->bindQueryIterator($contactRoles);\n\n $serviceMock = $this->getServiceMock();\n\n $data = $serviceMock->getContactRolesFromCrm(now()->subDay());\n\n $this->assertEquals($expectedResponse, $data);\n }\n\n public function testGetContactRolesFromCrmNoResult(): void\n {\n $this->bindQueryIterator([]);\n\n $serviceMock = $this->getServiceMock();\n\n $data = $serviceMock->getContactRolesFromCrm(now()->subDay());\n\n $this->assertEquals([], $data);\n }\n\n public function testSyncContactRoles(): void\n {\n $contactRoles = [\n [\n 'id' => '1',\n 'contactId' => 'Contact1',\n 'opportunityId' => 'Opportunity1',\n 'ownerId' => 'Owner1',\n 'isPrimary' => true,\n 'role' => 'Decision Maker',\n ],\n ];\n\n app()->bind(ContactRoleRepository::class, function () {\n $contactRoleRepository = $this->createMock(ContactRoleRepository::class);\n $contactRoleRepository->expects($this->once())\n ->method('saveContactRoles');\n\n return $contactRoleRepository;\n });\n\n $serviceMock = $this->getServiceMock([\n 'getContactRolesFromCrm',\n 'syncRemotelyDeletedContactRoles',\n 'syncContact',\n 'syncOpportunity',\n ]);\n\n $config = $this->createMock(Configuration::class);\n $hasMany = $this->createMock(HasManyExtended::class);\n $hasMany->expects($this->exactly(2))\n ->method('where')\n ->willReturn($hasMany);\n\n $hasMany->expects($this->exactly(2))\n ->method('first')\n ->willReturn(\n $this->createMock(Contact::class),\n $this->createMock(Opportunity::class)\n );\n\n $config->expects($this->once())\n ->method('contacts')\n ->willReturn($hasMany);\n $config->expects($this->once())\n ->method('opportunities')\n ->willReturn($hasMany);\n\n $serviceMock->config = $config;\n\n $serviceMock->expects($this->once())\n ->method('getContactRolesFromCrm')\n ->willReturn($contactRoles);\n\n $serviceMock->expects($this->once())\n ->method('syncRemotelyDeletedContactRoles');\n\n $data = $serviceMock->syncContactRoles(now()->subDay());\n\n $this->assertEquals(1, $data);\n }\n\n public function testSyncRemotelyDeletedContactRoles(): void\n {\n $contactRoles = [\n [\n 'id' => '1',\n 'crm_provider_id' => '1',\n ],\n ];\n\n app()->bind(QueryHandler::class, function () use ($contactRoles) {\n $queryResults = new QueryResults($contactRoles, 1, true, null);\n\n $handler = $this->createMock(QueryHandler::class);\n $handler->method('queryDeleted')\n ->willReturn($queryResults);\n\n return $handler;\n });\n\n app()->bind(ContactRoleRepository::class, function () {\n $contactRoleRepository = $this->createMock(ContactRoleRepository::class);\n $contactRoleRepository->expects($this->once())\n ->method('deleteContactRoles');\n\n return $contactRoleRepository;\n });\n\n $serviceMock = $this->getServiceMock();\n $serviceMock->team = $this->createMock(Team::class);\n\n $data = $this->invokePrivateMethod('syncRemotelyDeletedContactRoles', $serviceMock, []);\n\n $this->assertTrue($data);\n }\n\n private function bindQueryIterator(array $queryResult): void\n {\n /** @var Client $client */\n $client = $this->createMock(Client::class);\n $queryIterator = new QueryIterator(\n $client,\n new QueryResults($queryResult, 1, true, null)\n );\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('query')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n }\n\n public static function getOpportunitySortOrderDataProvider(): array\n {\n return [\n 'all open recently updated' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED, ['LastModifiedDate DESC', true]],\n 'all open recently created' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED, ['CreatedDate DESC', true]],\n 'all open oldest created' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED, ['CreatedDate ASC', true]],\n 'all recently updated' => [Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED, ['LastModifiedDate DESC', false]],\n 'default' => ['unknown', ['LastModifiedDate DESC', true]],\n ];\n }\n\n private function createMockedConfiguration(): Configuration\n {\n $config = $this->createMock(Configuration::class);\n $profilesRelation = $this->getMockBuilder(\\Illuminate\\Database\\Eloquent\\Relations\\HasMany::class)\n ->disableOriginalConstructor()\n ->onlyMethods(['get'])\n ->addMethods(['where', 'first'])\n ->getMock();\n $profilesRelation->method('where')->willReturnSelf();\n $profilesRelation->method('get')->willReturn(collect([]));\n $profilesRelation->method('first')->willReturn(null);\n $config->method('profiles')->willReturn($profilesRelation);\n\n return $config;\n }\n\n private function getServiceMock(array $onlyMethods = []): MockObject&Service\n {\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $this->createMock(Client::class),\n $this->createMock(PayloadBuilder::class),\n $this->createMock(Dispatcher::class),\n $this->createMock(CountriesMap::class),\n $this->createMock(ProspectPhotoPathService::class),\n ])\n ->onlyMethods($onlyMethods)\n ->getMock();\n\n $serviceMock->profile = $this->createMock(Profile::class);\n\n return $serviceMock;\n }\n\n private function mockQueryBuilderAndHandler($userToSearch, $salesforceUsers): void\n {\n app()->bind(QueryBuilder::class, function () use ($userToSearch) {\n $queryBuilder = $this->createMock(QueryBuilder::class);\n $queryBuilder->expects($this->once())\n ->method('buildGetUsersQuery')\n ->with($userToSearch)\n ->willReturn('SELECT * FROM Users');\n\n return $queryBuilder;\n });\n\n $queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults($salesforceUsers, count($salesforceUsers), true, null));\n\n app()->bind(QueryHandler::class, function () use ($queryIterator) {\n $handler = $this->createMock(QueryHandler::class);\n $handler->expects($this->any())\n ->method('query')\n ->willReturn($queryIterator);\n\n return $handler;\n });\n }\n\n private function mockTeamRepository(\n Team $team,\n array $salesforceUser,\n ?User $user = null,\n bool $userSearch = true,\n array $customRules = []\n ): void {\n $teamRepository = $this->getMockForAbstractClass(TeamRepository::class, [], '', false, true, true, ['findActiveTeamMemberByEmail', 'getTeamSetting']);\n\n if ($userSearch) {\n $teamRepository->expects($this->once())\n ->method('findActiveTeamMemberByEmail')\n ->with($team, $salesforceUser['Email'])\n ->willReturn($user);\n }\n\n $teamSettings = $this->createMock(TeamSettings::class);\n $teamSettings->method('getValueType')\n ->willReturn('array');\n\n $teamSettings->method('getValue')\n ->willReturn(json_encode($customRules));\n\n $teamRepository->expects($this->once())\n ->method('getTeamSetting')\n ->with($team, 'custom_profile_validation')\n ->willReturn($teamSettings);\n\n app()->bind(TeamRepository::class, function () use ($teamRepository) {\n return $teamRepository;\n });\n }\n\n private function mockProfileRepository(User $user): void\n {\n $profileRepository = $this->createMock(ProfileRepository::class);\n $profileRepository->expects($this->once())\n ->method('updateOrCreateProfile')\n ->with(\n $user,\n [\n 'crm_configuration_id' => 1,\n 'crm_provider_id' => '456',\n ],\n [\n 'user_id' => 123,\n 'edition' => Profile::EDITION_LIGHTNING,\n 'has_external_cti' => true,\n 'crm_profile_id' => '789',\n ]\n )\n ->willReturn(new Profile());\n\n $this->app->instance(ProfileRepository::class, $profileRepository);\n }\n\n public function testBuildEnhancedNoteDecodesHtmlEntities(): void\n {\n $service = $this->getServiceMock(['createRecord']);\n\n $profile = new Profile();\n $profile->setAttribute('crm_provider_id', 'owner-123');\n $service->profile = $profile;\n\n $service->expects($this->exactly(2))\n ->method('createRecord')\n ->willReturnOnConsecutiveCalls('note-id-123', 'link-id-456');\n\n $bodyWithEntities = 'Welch's current challenges and Facebook's Club';\n\n $result = $this->invokePrivateMethod('buildEnhancedNote', $service, [\n 'Test Title',\n $bodyWithEntities,\n 'object-id-789',\n ]);\n\n $this->assertEquals('note-id-123', $result);\n }\n\n public function testBuildEnhancedNoteSanitizesWithoutQuotes(): void\n {\n $service = $this->getServiceMock(['createRecord']);\n\n $profile = new Profile();\n $profile->setAttribute('crm_provider_id', 'owner-456');\n $service->profile = $profile;\n\n $service->expects($this->exactly(2))\n ->method('createRecord')\n ->willReturnCallback(function ($type, $data) {\n if ($type === 'ContentNote') {\n $decoded = base64_decode($data['Content']);\n $this->assertStringContainsString(\"Welch's\", $decoded);\n $this->assertStringNotContainsString(''', $decoded);\n $this->assertStringNotContainsString('&#039;', $decoded);\n $this->assertStringContainsString('<script>', $decoded);\n\n return 'note-id-456';\n }\n\n return 'link-id-789';\n });\n\n $bodyWithMixedContent = \"Welch's and <script>alert('xss')</script>\";\n\n $result = $this->invokePrivateMethod('buildEnhancedNote', $service, [\n 'Test Title',\n $bodyWithMixedContent,\n 'object-id-123',\n ]);\n\n $this->assertEquals('note-id-456', $result);\n }\n\n public function testBuildEnhancedNoteConvertsLineBreaks(): void\n {\n $service = $this->getServiceMock(['createRecord']);\n\n $profile = new Profile();\n $profile->setAttribute('crm_provider_id', 'owner-789');\n $service->profile = $profile;\n\n $service->expects($this->exactly(2))\n ->method('createRecord')\n ->willReturnCallback(function ($type, $data) {\n if ($type === 'ContentNote') {\n $decoded = base64_decode($data['Content']);\n $this->assertStringContainsString('<br>', $decoded);\n $this->assertStringNotContainsString('<br />', $decoded);\n\n return 'note-id-789';\n }\n\n return 'link-id-012';\n });\n\n $bodyWithLineBreaks = \"Line 1\\nLine 2\\nLine 3\";\n\n $result = $this->invokePrivateMethod('buildEnhancedNote', $service, [\n 'Test Title',\n $bodyWithLineBreaks,\n 'object-id-456',\n ]);\n\n $this->assertEquals('note-id-789', $result);\n }\n\n public function testBuildEnhancedNoteHandlesComplexScenario(): void\n {\n $service = $this->getServiceMock(['createRecord']);\n\n $profile = new Profile();\n $profile->setAttribute('crm_provider_id', 'owner-complex');\n $service->profile = $profile;\n\n $service->expects($this->exactly(2))\n ->method('createRecord')\n ->willReturnCallback(function ($type, $data) {\n if ($type === 'ContentNote') {\n $decoded = base64_decode($data['Content']);\n\n $this->assertStringContainsString(\"Welch's\", $decoded);\n $this->assertStringContainsString(\"Facebook's Club\", $decoded);\n $this->assertStringContainsString(\"Arctics'\", $decoded);\n $this->assertStringNotContainsString(''', $decoded);\n $this->assertStringNotContainsString('&#039;', $decoded);\n $this->assertStringContainsString('<br>', $decoded);\n $this->assertStringContainsString('<', $decoded);\n $this->assertStringContainsString('>', $decoded);\n\n return 'note-complex';\n }\n\n return 'link-complex';\n });\n\n $complexBody = \"Summary:\\n---------\\nThe call focused on understanding Welch's current challenges and exploring how Arctics' Revenue Growth Management solutions could support their strategic goals.\\n\\n• John SMith discussed his role as a category advisor for Google and Facebook's Club, emphasizing the importance of market research and advising on product assortment.\\n• Madona introduced Arctics' Virtual Shoppers AI, which simulates consumer <behavior> to optimize pricing and promotional strategies.\";\n\n $result = $this->invokePrivateMethod('buildEnhancedNote', $service, [\n 'Jiminny Transcription Summary',\n $complexBody,\n 'task-id-001',\n ]);\n\n $this->assertEquals('note-complex', $result);\n }\n\n public function testSyncRemotelyDeletedObjectsWithErrorHandlingSuccess(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['syncRemotelyDeletedObjects'])\n ->getMock();\n\n // Mock team\n $team = $this->createMock(Team::class);\n $team->method('getUuid')->willReturn('team-uuid-123');\n $serviceMock->team = $team;\n\n // Expect syncRemotelyDeletedObjects to be called once and succeed\n $serviceMock->expects($this->once())\n ->method('syncRemotelyDeletedObjects')\n ->with(\\Jiminny\\Enums\\CrmObject::ACCOUNT);\n\n // Call the protected method using reflection\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');\n $method->setAccessible(true);\n\n // Should not throw any exceptions\n $method->invoke($serviceMock, \\Jiminny\\Enums\\CrmObject::ACCOUNT);\n\n $this->assertTrue(true); // Test completed successfully\n }\n\n public function testSyncRemotelyDeletedObjectsWithErrorHandlingFailure(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['syncRemotelyDeletedObjects'])\n ->getMock();\n\n // Mock team\n $team = $this->createMock(Team::class);\n $team->method('getUuid')->willReturn('team-uuid-456');\n $serviceMock->team = $team;\n\n // Mock logger to verify warning is logged\n $logger = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n\n // Use reflection to set the protected logger property\n $reflection = new \\ReflectionClass($serviceMock);\n $loggerProperty = $reflection->getProperty('logger');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($serviceMock, $logger);\n\n $exception = new \\Exception('Sync failed due to API error');\n\n // Expect syncRemotelyDeletedObjects to throw an exception\n $serviceMock->expects($this->once())\n ->method('syncRemotelyDeletedObjects')\n ->with(\\Jiminny\\Enums\\CrmObject::CONTACT)\n ->willThrowException($exception);\n\n // Expect warning to be logged with correct message and parameters\n $logger->expects($this->once())\n ->method('warning')\n ->with(\n '[Salesforce] Remotely deleted objects sync failed',\n [\n 'objectType' => 'contact',\n 'teamId' => 'team-uuid-456',\n 'reason' => 'Sync failed due to API error',\n ]\n );\n\n // Call the protected method using reflection\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');\n $method->setAccessible(true);\n\n // Should not re-throw the exception, just log it\n $method->invoke($serviceMock, \\Jiminny\\Enums\\CrmObject::CONTACT);\n\n $this->assertTrue(true); // Test completed successfully\n }\n\n public function testSyncRemotelyDeletedObjectsWithErrorHandlingWithLogParams(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['syncRemotelyDeletedObjects'])\n ->getMock();\n\n // Mock team\n $team = $this->createMock(Team::class);\n $team->method('getUuid')->willReturn('team-uuid-789');\n $serviceMock->team = $team;\n\n // Mock logger to verify warning is logged\n $logger = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n\n // Use reflection to set the protected logger property\n $loggerReflection = new \\ReflectionClass($serviceMock);\n $loggerProperty = $loggerReflection->getProperty('logger');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($serviceMock, $logger);\n\n $exception = new \\Exception('Network timeout');\n\n // Expect syncRemotelyDeletedObjects to throw an exception\n $serviceMock->expects($this->once())\n ->method('syncRemotelyDeletedObjects')\n ->with(\\Jiminny\\Enums\\CrmObject::OPPORTUNITY)\n ->willThrowException($exception);\n\n // Additional log parameters\n $logParams = [\n 'syncType' => 'full',\n 'batchSize' => 100,\n ];\n\n // Expect warning to be logged with merged parameters\n $logger->expects($this->once())\n ->method('warning')\n ->with(\n '[Salesforce] Remotely deleted objects sync failed',\n [\n 'objectType' => 'opportunity',\n 'teamId' => 'team-uuid-789',\n 'reason' => 'Network timeout',\n 'syncType' => 'full',\n 'batchSize' => 100,\n ]\n );\n\n // Call the protected method using reflection\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');\n $method->setAccessible(true);\n\n // Should not re-throw the exception, just log it\n $method->invoke($serviceMock, \\Jiminny\\Enums\\CrmObject::OPPORTUNITY, $logParams);\n\n $this->assertTrue(true); // Test completed successfully\n }\n\n /**\n * @dataProvider crmObjectProvider\n */\n public function testSyncRemotelyDeletedObjectsWithErrorHandlingDifferentCrmObjects(\\Jiminny\\Enums\\CrmObject $crmObject): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['syncRemotelyDeletedObjects'])\n ->getMock();\n\n // Mock team\n $team = $this->createMock(Team::class);\n $team->method('getUuid')->willReturn('team-uuid-test');\n $serviceMock->team = $team;\n\n // Mock logger to verify warning is logged\n $logger = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n\n // Use reflection to set the protected logger property\n $loggerReflectionClass = new \\ReflectionClass($serviceMock);\n $loggerProperty = $loggerReflectionClass->getProperty('logger');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($serviceMock, $logger);\n\n $exception = new \\Exception('Test error');\n\n // Expect syncRemotelyDeletedObjects to throw an exception\n $serviceMock->expects($this->once())\n ->method('syncRemotelyDeletedObjects')\n ->with($crmObject)\n ->willThrowException($exception);\n\n // Expect warning to be logged with correct entity type\n $logger->expects($this->once())\n ->method('warning')\n ->with(\n '[Salesforce] Remotely deleted objects sync failed',\n [\n 'objectType' => $crmObject->value,\n 'teamId' => 'team-uuid-test',\n 'reason' => 'Test error',\n ]\n );\n\n // Call the protected method using reflection\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');\n $method->setAccessible(true);\n\n $method->invoke($serviceMock, $crmObject);\n\n $this->assertTrue(true); // Test completed successfully\n }\n\n public function testHandleObjectDeletionWithDeletedEntity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['deleteCrmObject'])\n ->getMock();\n\n $entity = $this->createMock(\\Jiminny\\Models\\Account::class);\n $crmData = ['IsDeleted' => true];\n\n $serviceMock->expects($this->once())\n ->method('deleteCrmObject')\n ->with($entity);\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('handleObjectDeletion');\n $method->setAccessible(true);\n\n $method->invoke($serviceMock, $entity, $crmData);\n }\n\n public function testHandleObjectDeletionWithNonDeletedEntity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['deleteCrmObject'])\n ->getMock();\n\n $entity = $this->createMock(\\Jiminny\\Models\\Contact::class);\n $crmData = ['IsDeleted' => false];\n\n $serviceMock->expects($this->never())\n ->method('deleteCrmObject');\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('handleObjectDeletion');\n $method->setAccessible(true);\n\n $method->invoke($serviceMock, $entity, $crmData);\n }\n\n public function testDeleteCrmObjectWithValidEntity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['dispatchDeleteCrmObjectJob'])\n ->getMock();\n\n $entity = $this->createMock(\\Jiminny\\Models\\Lead::class);\n $entity->expects($this->once())->method('delete');\n\n $serviceMock->expects($this->once())\n ->method('dispatchDeleteCrmObjectJob')\n ->with($entity);\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('deleteCrmObject');\n $method->setAccessible(true);\n\n $method->invoke($serviceMock, $entity);\n }\n\n public function testDeleteCrmObjectWithNullEntity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n /** @var Service&MockObject $serviceMock */\n $serviceMock = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['dispatchDeleteCrmObjectJob'])\n ->getMock();\n\n $serviceMock->expects($this->never())\n ->method('dispatchDeleteCrmObjectJob');\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($serviceMock);\n $method = $reflection->getMethod('deleteCrmObject');\n $method->setAccessible(true);\n\n $method->invoke($serviceMock, null);\n }\n\n public function testDispatchDeleteCrmObjectJobWithNullEntity(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($service);\n $method = $reflection->getMethod('dispatchDeleteCrmObjectJob');\n $method->setAccessible(true);\n\n // Should return early without dispatching - no exception expected\n $method->invoke($service, null);\n\n $this->assertTrue(true); // Test completed successfully\n }\n\n public function testDispatchDeleteCrmObjectJobWithUnsupportedEntity(): void\n {\n $this->expectException(\\TypeError::class);\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n $unsupportedEntity = $this->createMock(\\stdClass::class);\n\n // Use reflection to call the protected method\n $reflection = new \\ReflectionClass($service);\n $method = $reflection->getMethod('dispatchDeleteCrmObjectJob');\n\n // This will throw TypeError due to union type constraint\n $method->invoke($service, $unsupportedEntity);\n }\n\n public function testHandleEntityDeletionByProviderIdMethodExists(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n // Test that the method exists and is accessible via reflection\n $reflection = new \\ReflectionClass($service);\n $method = $reflection->getMethod('handleEntityDeletionByProviderId');\n $method->setAccessible(true);\n\n // Verify method exists and has correct parameters\n $this->assertTrue($method->isProtected());\n $this->assertEquals(2, $method->getNumberOfParameters());\n\n $parameters = $method->getParameters();\n $this->assertEquals('targetEntity', $parameters[0]->getName());\n $this->assertEquals('crmData', $parameters[1]->getName());\n }\n\n public function testSyncRemotelyDeletedObjectsWithNoResults(): void\n {\n // Create a real service instance to avoid mock property issues\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n // Mock queryHandler to throw NoResultsException\n $queryHandler = $this->createMock(QueryHandler::class);\n $queryHandler->expects($this->once())\n ->method('queryDeleted')\n ->with('Opportunity')\n ->willThrowException(new NoResultsException('No results'));\n\n // Set the queryHandler using reflection on the real service\n $reflection = new \\ReflectionClass($service);\n $queryHandlerProperty = $reflection->getProperty('queryHandler');\n $queryHandlerProperty->setAccessible(true);\n $queryHandlerProperty->setValue($service, $queryHandler);\n\n $result = self::invokePrivateMethod('syncRemotelyDeletedObjects', $service, [CrmObject::OPPORTUNITY]);\n\n $this->assertFalse($result);\n }\n\n public function testSyncRemotelyDeletedObjectsWithEmptyResults(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n // Mock queryHandler to return empty results\n $queryResult = $this->createMock(QueryResults::class);\n $queryResult->method('getResults')->willReturn([]);\n\n $queryHandler = $this->createMock(QueryHandler::class);\n $queryHandler->expects($this->once())\n ->method('queryDeleted')\n ->with('Opportunity')\n ->willReturn($queryResult);\n\n // Set the queryHandler using reflection on the real service\n $reflection = new \\ReflectionClass($service);\n $queryHandlerProperty = $reflection->getProperty('queryHandler');\n $queryHandlerProperty->setAccessible(true);\n $queryHandlerProperty->setValue($service, $queryHandler);\n\n $result = self::invokePrivateMethod('syncRemotelyDeletedObjects', $service, [CrmObject::OPPORTUNITY]);\n\n $this->assertFalse($result);\n }\n\n public function testSyncRemotelyDeletedObjectsWithUnsupportedCrmObject(): void\n {\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = new Service(\n client: $client,\n payloadBuilder: $payloadBuilder,\n eventDispatcher: $eventDispatcher,\n countriesMap: $countriesMap,\n prospectPhotoPathService: $prospectPhotoPathService,\n );\n\n // Mock queryHandler to return some deleted objects so we reach the match statement\n $deletedObjects = [\n ['id' => 'task1'],\n ['id' => 'task2'],\n ];\n $queryResult = $this->createMock(QueryResults::class);\n $queryResult->method('getResults')->willReturn($deletedObjects);\n\n $queryHandler = $this->createMock(QueryHandler::class);\n $queryHandler->expects($this->once())\n ->method('queryDeleted')\n ->with('Task') // ucfirst('task') = 'Task'\n ->willReturn($queryResult);\n\n self::setPrivateProperty($service, 'queryHandler', $queryHandler);\n\n $this->expectException(InvalidArgumentException::class);\n $this->expectExceptionMessage('Unsupported CrmObject: task');\n\n self::invokePrivateMethod('syncRemotelyDeletedObjects', $service, [CrmObject::TASK]);\n }\n\n public static function crmObjectProvider(): array\n {\n return [\n 'Account' => [CrmObject::ACCOUNT],\n 'Contact' => [CrmObject::CONTACT],\n 'Lead' => [CrmObject::LEAD],\n 'Opportunity' => [CrmObject::OPPORTUNITY],\n ];\n }\n\n public function testVerifyTaskExistsReturnsTrue(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-123', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-123');\n $activity->method('getId')->willReturn(456);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-123', ['Id', 'IsDeleted'])\n ->willReturn(['Id' => 'task-123', 'IsDeleted' => false]);\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertTrue($result);\n }\n\n public function testVerifyTaskExistsReturnsTrueForEvent(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:event-123', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('event-123');\n $activity->method('getId')->willReturn(456);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_EVENT);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Event', 'event-123', ['Id', 'IsDeleted'])\n ->willReturn(['Id' => 'event-123', 'IsDeleted' => false]);\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertTrue($result);\n }\n\n public function testVerifyTaskExistsReturnsFalseWhenDeleted(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-456', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-456');\n $activity->method('getId')->willReturn(789);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-456', ['Id', 'IsDeleted'])\n ->willReturn(['Id' => 'task-456', 'IsDeleted' => true]);\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertFalse($result);\n }\n\n public function testVerifyTaskExistsReturnsFalseWhenNotFound(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-999', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-999');\n $activity->method('getId')->willReturn(999);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-999', ['Id', 'IsDeleted'])\n ->willThrowException(new \\Jiminny\\Exceptions\\HttpNotFoundException('Task not found'));\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertFalse($result);\n }\n\n public function testVerifyTaskExistsReturnsFalseWhenNoPlaybook(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-no-playbook', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-no-playbook');\n $activity->method('getId')->willReturn(111);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn(null);\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertFalse($result);\n }\n\n public function testVerifyTaskExistsThrowsExceptionForTransientErrors(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-error', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-error');\n $activity->method('getId')->willReturn(888);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-error', ['Id', 'IsDeleted'])\n ->willThrowException(new \\RuntimeException('Network timeout'));\n\n $this->expectException(\\RuntimeException::class);\n $this->expectExceptionMessage('Network timeout');\n\n $service->verifyTaskExists($activity);\n }\n\n public function testVerifyTaskExistsCachesResults(): void\n {\n $cachedValue = null;\n Cache::shouldReceive('remember')\n ->twice()\n ->with('crm_task_exists:123:task-cached', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(function ($key, $ttl, $callback) use (&$cachedValue) {\n if ($cachedValue === null) {\n $cachedValue = $callback();\n }\n\n return $cachedValue;\n });\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-cached');\n $activity->method('getId')->willReturn(555);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-cached', ['Id', 'IsDeleted'])\n ->willReturn(['Id' => 'task-cached', 'IsDeleted' => false]);\n\n $result1 = $service->verifyTaskExists($activity);\n $result2 = $service->verifyTaskExists($activity);\n\n $this->assertTrue($result1);\n $this->assertTrue($result2);\n }\n\n public function testVerifyTaskExistsReturnsFalseForHttpBadRequestException(): void\n {\n Cache::shouldReceive('remember')\n ->once()\n ->with('crm_task_exists:123:task-400', 86400, \\Mockery::type('Closure'))\n ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n $eventDispatcher = $this->createMock(Dispatcher::class);\n\n $service = $this->getMockBuilder(Service::class)\n ->setConstructorArgs([\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService,\n ])\n ->onlyMethods(['getRecord', 'getPlaybookFromActivity'])\n ->getMock();\n\n $config = $this->createMock(Configuration::class);\n $config->method('getId')->willReturn(123);\n\n self::setPrivateProperty($service, 'config', $config);\n\n $activity = $this->createMock(Activity::class);\n $activity->method('getCrmProviderId')->willReturn('task-400');\n $activity->method('getId')->willReturn(400);\n\n $playbook = $this->createMock(Playbook::class);\n $playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);\n\n $service->expects($this->once())\n ->method('getPlaybookFromActivity')\n ->with($activity)\n ->willReturn($playbook);\n\n $service->expects($this->once())\n ->method('getRecord')\n ->with('Task', 'task-400', ['Id', 'IsDeleted'])\n ->willThrowException(new \\Jiminny\\Exceptions\\HttpBadRequestException('Bad request'));\n\n $result = $service->verifyTaskExists($activity);\n\n $this->assertFalse($result);\n }\n\n public function testImportOpportunitySkipsWhenNoProfileAndNoAccount(): void\n {\n $crmData = [\n 'Id' => 'SF-NO-USER-1',\n 'Name' => 'Test Opportunity',\n 'OwnerId' => 'owner-no-profile',\n // No AccountId\n ];\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $countriesMap = $this->createMock(CountriesMap::class);\n $eventDispatcher = $this->createMock(\\Illuminate\\Events\\Dispatcher::class); // ← ADD THIS\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n\n $service = new Service(\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService\n );\n\n $config = $this->createMock(Configuration::class);\n\n // Mock profiles relation returning null (no profile found)\n $profilesRelation = \\Mockery::mock(\\Illuminate\\Database\\Eloquent\\Relations\\HasMany::class);\n $profilesRelation->shouldReceive('where')->with('crm_provider_id', 'owner-no-profile')->andReturnSelf();\n $profilesRelation->shouldReceive('first')->andReturn(null);\n\n $config->expects($this->once())\n ->method('profiles')\n ->willReturn($profilesRelation);\n\n $team = $this->createMock(Team::class);\n $team->method('getId')->willReturn(1);\n\n $logger = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n $logger->expects($this->once())\n ->method('error')\n ->with(\n '[Salesforce] | Skip import, no user_id found',\n ['id' => 'SF-NO-USER-1']\n );\n\n $reflection = new \\ReflectionClass($service);\n\n $configProperty = $reflection->getProperty('config');\n $configProperty->setAccessible(true);\n $configProperty->setValue($service, $config);\n\n $teamProperty = $reflection->getProperty('team');\n $teamProperty->setAccessible(true);\n $teamProperty->setValue($service, $team);\n\n $loggerProperty = $reflection->getProperty('logger');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($service, $logger);\n\n // Initialize profile property to avoid \"must not be accessed before initialization\" error\n $profileProperty = $reflection->getProperty('profile');\n $profileProperty->setAccessible(true);\n $profileProperty->setValue($service, null);\n\n $result = self::invokePrivateMethod('importOpportunity', $service, [$crmData]);\n\n $this->assertNull($result);\n }\n\n public function testImportOpportunitySkipsWhenProfileNotFound(): void\n {\n $crmData = [\n 'Id' => 'SF-NO-USER-2',\n 'Name' => 'Test Opportunity',\n 'OwnerId' => 'owner-not-found',\n // No AccountId - avoid complex account processing\n ];\n\n $client = $this->createMock(Client::class);\n $payloadBuilder = $this->createMock(PayloadBuilder::class);\n $eventDispatcher = $this->createMock(\\Illuminate\\Events\\Dispatcher::class); // ← ADD THIS\n $countriesMap = $this->createMock(CountriesMap::class);\n $prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);\n\n\n $service = new Service(\n $client,\n $payloadBuilder,\n $eventDispatcher,\n $countriesMap,\n $prospectPhotoPathService\n );\n\n $config = $this->createMock(Configuration::class);\n\n // Mock profiles relation returning null (no profile found)\n $profilesRelation = \\Mockery::mock(\\Illuminate\\Database\\Eloquent\\Relations\\HasMany::class);\n $profilesRelation->shouldReceive('where')->with('crm_provider_id', 'owner-not-found')->andReturnSelf();\n $profilesRelation->shouldReceive('first')->andReturn(null);\n\n $config->expects($this->once())\n ->method('profiles')\n ->willReturn($profilesRelation);\n\n $team = $this->createMock(Team::class);\n $team->method('getId')->willReturn(1);\n\n $logger = $this->createMock(\\Psr\\Log\\LoggerInterface::class);\n $logger->expects($this->once())\n ->method('error')\n ->with(\n '[Salesforce] | Skip import, no user_id found',\n ['id' => 'SF-NO-USER-2']\n );\n\n $reflection = new \\ReflectionClass($service);\n\n $configProperty = $reflection->getProperty('config');\n $configProperty->setAccessible(true);\n $configProperty->setValue($service, $config);\n\n $teamProperty = $reflection->getProperty('team');\n $teamProperty->setAccessible(true);\n $teamProperty->setValue($service, $team);\n\n $loggerProperty = $reflection->getProperty('logger');\n $loggerProperty->setAccessible(true);\n $loggerProperty->setValue($service, $logger);\n\n // Initialize profile property\n $profileProperty = $reflection->getProperty('profile');\n $profileProperty->setAccessible(true);\n $profileProperty->setValue($service, null);\n\n $result = self::invokePrivateMethod('importOpportunity', $service, [$crmData]);\n\n $this->assertNull($result);\n }\n}\n\nclass HasManyExtended extends HasMany\n{\n public function where()\n {\n }\n\n public function first()\n {\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"A","depth":4,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":5,"bounds":{"left":0.27027926,"top":1.0,"width":0.0039893617,"height":0.0},"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Select All","depth":4,"bounds":{"left":0.42652926,"top":0.14365523,"width":0.09009308,"height":0.0207502},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"320","depth":6,"bounds":{"left":0.5169548,"top":0.16520351,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"320","depth":7,"bounds":{"left":0.5169548,"top":0.16520351,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"5772","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"5772","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"46ea38f7-c940-395d-9c79-0329d30e9ab7","depth":6,"bounds":{"left":0.5169548,"top":0.1867518,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"46ea38f7-c940-395d-9c79-0329d30e9ab7","depth":7,"bounds":{"left":0.5169548,"top":0.1867518,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"2024-11-26 09:21:15","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"2024-11-26 09:21:15","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"401","depth":6,"bounds":{"left":0.5169548,"top":0.20830008,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"401","depth":7,"bounds":{"left":0.5169548,"top":0.20830008,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"5772","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"5772","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"<null>","depth":6,"bounds":{"left":0.5169548,"top":0.22984837,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"<null>","depth":7,"bounds":{"left":0.5169548,"top":0.22984837,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"5772","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"5772","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"salesforce","depth":6,"bounds":{"left":0.5169548,"top":0.25139666,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"salesforce","depth":7,"bounds":{"left":0.5169548,"top":0.25139666,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"2024-11-26 09:21:15","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"2024-11-26 09:21:15","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"enterprise","depth":6,"bounds":{"left":0.5169548,"top":0.27294493,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"enterprise","depth":7,"bounds":{"left":0.5169548,"top":0.27294493,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"2024-11-26 09:21:15","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"2024-11-26 09:21:15","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"SWE38","depth":6,"bounds":{"left":0.5169548,"top":0.29449323,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"SWE38","depth":7,"bounds":{"left":0.5169548,"top":0.29449323,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"2024-11-26 09:21:15","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"2024-11-26 09:21:15","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"0","depth":6,"bounds":{"left":0.5169548,"top":0.3160415,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"0","depth":7,"bounds":{"left":0.5169548,"top":0.3160415,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"5772","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"5772","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"50.0","depth":6,"bounds":{"left":0.5169548,"top":0.33758977,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"50.0","depth":7,"bounds":{"left":0.5169548,"top":0.33758977,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"2024-11-26 09:21:15","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"2024-11-26 09:21:15","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"01801","depth":6,"bounds":{"left":0.5169548,"top":0.35913807,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"01801","depth":7,"bounds":{"left":0.5169548,"top":0.35913807,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"2024-11-26 09:21:15","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"2024-11-26 09:21:15","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"1","depth":6,"bounds":{"left":0.5169548,"top":0.38068634,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"1","depth":7,"bounds":{"left":0.5169548,"top":0.38068634,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"5772","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"5772","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"1","depth":6,"bounds":{"left":0.5169548,"top":0.40223464,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"1","depth":7,"bounds":{"left":0.5169548,"top":0.40223464,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"5772","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"5772","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"1","depth":6,"bounds":{"left":0.5169548,"top":0.4237829,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"1","depth":7,"bounds":{"left":0.5169548,"top":0.4237829,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"5772","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"5772","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"00D20000000MdbgEAC","depth":6,"bounds":{"left":0.5169548,"top":0.44533122,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"00D20000000MdbgEAC","depth":7,"bounds":{"left":0.5169548,"top":0.44533122,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"2024-11-26 09:21:15","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"2024-11-26 09:21:15","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"https://bipsolutions.my.salesforce.com","depth":6,"bounds":{"left":0.5169548,"top":0.4668795,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"https://bipsolutions.my.salesforce.com","depth":7,"bounds":{"left":0.5169548,"top":0.4668795,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"2024-11-26 09:21:15","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"2024-11-26 09:21:15","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"2026-03-19 08:19:24","depth":6,"bounds":{"left":0.5169548,"top":0.4884278,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"2026-03-19 08:19:24","depth":7,"bounds":{"left":0.5169548,"top":0.4884278,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"2024-11-26 09:21:15","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"2024-11-26 09:21:15","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"2026-05-22 09:48:21","depth":6,"bounds":{"left":0.5169548,"top":0.509976,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"2026-05-22 09:48:21","depth":7,"bounds":{"left":0.5169548,"top":0.509976,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"2024-11-26 09:21:15","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"2024-11-26 09:21:15","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"2026-05-22 09:48:31","depth":6,"bounds":{"left":0.5169548,"top":0.53152436,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"2026-05-22 09:48:31","depth":7,"bounds":{"left":0.5169548,"top":0.53152436,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"2024-11-26 09:21:15","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"2024-11-26 09:21:15","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"2026-05-22 09:48:43","depth":6,"bounds":{"left":0.5169548,"top":0.55307263,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"2026-05-22 09:48:43","depth":7,"bounds":{"left":0.5169548,"top":0.55307263,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"2024-11-26 09:21:15","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"2024-11-26 09:21:15","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"2026-05-22 09:48:29","depth":6,"bounds":{"left":0.5169548,"top":0.5746209,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"2026-05-22 09:48:29","depth":7,"bounds":{"left":0.5169548,"top":0.5746209,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"2024-11-26 09:21:15","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"2024-11-26 09:21:15","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"2026-05-22 09:48:40","depth":6,"bounds":{"left":0.5169548,"top":0.5961692,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"2026-05-22 09:48:40","depth":7,"bounds":{"left":0.5169548,"top":0.5961692,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"2024-11-26 09:21:15","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"2024-11-26 09:21:15","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"<null>","depth":6,"bounds":{"left":0.5169548,"top":0.6177175,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"<null>","depth":7,"bounds":{"left":0.5169548,"top":0.6177175,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"2024-11-26 09:21:15","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"2024-11-26 09:21:15","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"<null>","depth":6,"bounds":{"left":0.5169548,"top":0.6392658,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"<null>","depth":7,"bounds":{"left":0.5169548,"top":0.6392658,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"2024-11-26 09:21:15","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"2024-11-26 09:21:15","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"{\"opportunity_sync_period\":\"3m\",\"opportunity_sync_strategies\":[\"lastModified\"]}","depth":6,"bounds":{"left":0.5169548,"top":0.66081405,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"{\"opportunity_sync_period\":\"3m\",\"opportunity_sync_strategies\":[\"lastModified\"]}","depth":7,"bounds":{"left":0.5169548,"top":0.66081405,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"{\"opportunity_sync_period\":\"3m\",\"opp","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"{\"opportunity_sync_period\":\"3m\",\"opp","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"open-recently-updated","depth":6,"bounds":{"left":0.5169548,"top":0.6823623,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"open-recently-updated","depth":7,"bounds":{"left":0.5169548,"top":0.6823623,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"2024-11-26 09:21:15","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"2024-11-26 09:21:15","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"250000000.00","depth":6,"bounds":{"left":0.5169548,"top":0.7039106,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"250000000.00","depth":7,"bounds":{"left":0.5169548,"top":0.7039106,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"5772","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"5772","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"5772","depth":6,"bounds":{"left":0.5169548,"top":0.7254589,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"},{"role":"AXStaticText","text":"5772","depth":7,"bounds":{"left":0.5169548,"top":0.7254589,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"text"},{"role":"AXTextArea","text":"5772","depth":8,"bounds":{"left":0.27027926,"top":1.0,"width":0.098071806,"height":0.0},"on_screen":false,"value":"5772","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCell","text":"<null>","depth":6,"bounds":{"left":0.5169548,"top":0.7470072,"width":0.10006649,"height":0.0207502},"on_screen":true,"role_description":"cell"}]...
|
-5682313596501313866
|
-6520523286568608987
|
visual_change
|
accessibility
|
NULL
|
Project: faVsco.js, menu
JY-20963-fix-import-on-de Project: faVsco.js, menu
JY-20963-fix-import-on-deleted-entity, menu
Start Listening for PHP Debug Connections
ServiceTest
Rerun 'PHPUnit: ServiceTest'
Debug 'ServiceTest'
Stop 'ServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Built-in Preview
Chrome
Firefox
Safari
Sync Changes
Hide This Notification
Code changed:
Hide
4
32
168
1
5
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Tests\Unit\Services\Crm\Salesforce;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Events\Dispatcher;
use Illuminate\Support\Facades\Cache;
use Jiminny\Component\Country\CountriesMap;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Enums\CrmObject;
use Jiminny\Exceptions\InvalidArgumentException;
use Jiminny\Exceptions\NoResultsException;
use Jiminny\Integrations\PlaybookResolver;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Playbook;
use Jiminny\Models\Team;
use Jiminny\Models\TeamSettings;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\ContactRoleRepository;
use Jiminny\Repositories\Crm\FieldDataRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\Salesforce\Client;
use Jiminny\Services\Crm\Salesforce\PayloadBuilder;
use Jiminny\Services\Crm\Salesforce\QueryBuilder;
use Jiminny\Services\Crm\Salesforce\QueryHandler;
use Jiminny\Services\Crm\Salesforce\QueryIterator;
use Jiminny\Services\Crm\Salesforce\QueryResults;
use Jiminny\Services\Crm\Salesforce\Service;
use PHPUnit\Framework\MockObject\MockObject;
use Tests\TestCase;
use Tests\Unit\Traits\TestPrivateMethod;
class ServiceTest extends TestCase
{
use TestPrivateMethod;
public function testFetchAndAssociateRelatedActivity(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$payloadBuilder->method('addCustomLogicFieldsPayload')
->willReturnCallback(function ($activity, $payload) {
return $payload;
});
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods(['fetchRelatedActivity', 'getPlaybook', 'getPlaybookCategory', 'updateRecord'])
->getMock();
$serviceMock->expects($this->once())
->method('fetchRelatedActivity')
->willReturn([
'Id' => 'testId',
'Type' => null,
'OwnerId' => 'testerUser',
'Description' => 'Test description',
]);
$user = $this->createMock(User::class);
$team = $this->createMock(Team::class);
$user->method('getAttribute')->with('team')->willReturn($team);
$playbook = $this->createMock(Playbook::class);
$playbook->method('getActivityField')->willReturn(null);
$playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_EVENT);
$serviceMock->expects($this->once())
->method('getPlaybook')
->with($user)
->willReturn($playbook);
$serviceMock->expects($this->never())
->method('getPlaybookCategory');
$serviceMock->expects($this->never())
->method('updateRecord');
$fieldDataRepository = $this->createMock(FieldDataRepository::class);
$fieldDataRepository->method('getActivityFieldData')->willReturn(collect([]));
app()->instance(FieldDataRepository::class, $fieldDataRepository);
$config = $this->createMock(Configuration::class);
$profilesRelation = $this->getMockBuilder(\Illuminate\Database\Eloquent\Relations\HasMany::class)
->disableOriginalConstructor()
->onlyMethods(['get'])
->addMethods(['where'])
->getMock();
$profilesRelation->method('where')->willReturnSelf();
$profilesRelation->method('get')->willReturn(collect([]));
$config->method('profiles')->willReturn($profilesRelation);
$serviceMock->config = $config;
$serviceMock->profile = null;
$actualStartTime = \Carbon\Carbon::now();
$activity = $this->getMockBuilder(Activity::class)
->disableOriginalConstructor()
->onlyMethods(['update', 'hasProspect'])
->getMock();
$activity->method('update')->willReturn(true);
$activity->method('hasProspect')->willReturn(true);
$activity->type = Activity::TYPE_CONFERENCE;
$activity->provider = Activity::PROVIDER_TWILIO;
$activity->lead_id = 1;
$activity->user_id = 0;
$activity->id_string = 'test-activity-id';
$activity->user = $user;
$activity->actual_start_time = $actualStartTime;
$activity->uuid = 'c53d8320-f556-4cee-a2f8-5f232f454ca4';
app()->bind(PlaybookResolver::class, function () use ($user) {
$playbook = $this->createMock(Playbook::class);
$playbookResolver = $this->createMock(PlaybookResolver::class);
$playbookResolver->expects($this->once())
->method('resolvePlaybookByUser')
->with($user)
->willReturn($playbook);
return $playbookResolver;
});
$data = $serviceMock->fetchAndAssociateRelatedActivity($activity);
$this->assertInstanceOf(Activity::class, $data);
$this->assertEquals(Activity::TYPE_CONFERENCE, $data->getType());
$this->assertEquals($actualStartTime->getTimestamp(), $data->getActualStartTime()->getTimestamp());
}
public function testFetchAndAssociateRelatedActivitySkipsForTaskBasedPlaybook(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods(['fetchRelatedActivity', 'getPlaybook'])
->getMock();
$user = $this->createMock(User::class);
$playbook = $this->createMock(Playbook::class);
$playbook->method('getActivityType')->willReturn(Playbook::ACTIVITY_TYPE_TASK);
$playbook->method('getId')->willReturn(123);
$serviceMock->expects($this->once())
->method('getPlaybook')
->with($user)
->willReturn($playbook);
$serviceMock->expects($this->never())
->method('fetchRelatedActivity');
$activity = $this->getMockBuilder(Activity::class)
->disableOriginalConstructor()
->onlyMethods(['hasProspect', 'getUuid'])
->getMock();
$activity->method('hasProspect')->willReturn(true);
$activity->method('getUuid')->willReturn('c53d8320-f556-4cee-a2f8-5f232f454ca4');
$activity->type = Activity::TYPE_CONFERENCE;
$activity->actual_start_time = \Carbon\Carbon::now();
$activity->user = $user;
$result = $serviceMock->fetchAndAssociateRelatedActivity($activity);
$this->assertNull($result);
}
public function testFetchAndAssociateRelatedActivityReturnsNullForNonConference(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$serviceMock = new Service(
client: $client,
payloadBuilder: $payloadBuilder,
eventDispatcher: $eventDispatcher,
countriesMap: $countriesMap,
prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)
);
$activity = $this->getMockBuilder(Activity::class)
->disableOriginalConstructor()
->getMock();
$activity->type = Activity::TYPE_SOFTPHONE;
$result = $serviceMock->fetchAndAssociateRelatedActivity($activity);
$this->assertNull($result);
}
public function testFetchAndAssociateRelatedActivityReturnsNullWhenNoStartTime(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$serviceMock = new Service(
client: $client,
payloadBuilder: $payloadBuilder,
eventDispatcher: $eventDispatcher,
countriesMap: $countriesMap,
prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)
);
$activity = $this->getMockBuilder(Activity::class)
->disableOriginalConstructor()
->getMock();
$activity->type = Activity::TYPE_CONFERENCE;
$activity->actual_start_time = null;
$activity->scheduled_start_time = null;
$result = $serviceMock->fetchAndAssociateRelatedActivity($activity);
$this->assertNull($result);
}
public function testFetchAndAssociateRelatedActivityReturnsNullWhenNoProspect(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods(['getPlaybook'])
->getMock();
$serviceMock->expects($this->never())
->method('getPlaybook');
$activity = $this->getMockBuilder(Activity::class)
->disableOriginalConstructor()
->onlyMethods(['hasProspect', 'getUuid'])
->getMock();
$activity->method('hasProspect')->willReturn(false);
$activity->method('getUuid')->willReturn('c53d8320-f556-4cee-a2f8-5f232f454ca4');
$activity->type = Activity::TYPE_CONFERENCE;
$activity->actual_start_time = \Carbon\Carbon::now();
$result = $serviceMock->fetchAndAssociateRelatedActivity($activity);
$this->assertNull($result);
}
public function testMatchExactlyByEmail(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods([])
->getMock();
$profile = new Profile();
$profile->setAttribute('id', bin2hex(random_bytes(8)));
$serviceMock->profile = $profile;
$team = $this->createMock(Team::class);
$serviceMock->team = $team;
$data = $serviceMock->matchExactlyByEmail(bin2hex(random_bytes(8)) . '[EMAIL]');
$this->assertEquals(null, $data);
}
public function testMatchDomainFromEmail(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$queryIterator = $this->createMock(QueryIterator::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$config = $this->createMock(Configuration::class);
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('search')
->willReturn($queryIterator);
return $handler;
});
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods(['convertCrmData'])
->getMock();
$profile = new Profile();
$profile->account_fields = 'Field1, Field2, Field3';
$serviceMock->profile = $profile;
$serviceMock->expects($this->once())
->method('convertCrmData')
->willReturn(['test']);
$this->app->bind(QueryBuilder::class, function () {
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->expects($this->once())
->method('buildMatchByDomainQuery')
->with('[EMAIL]')
->willReturn('FIND {[EMAIL]} IN ALL FIELDS RETURNING Account(Id)');
return $queryBuilder;
});
$team = $this->createMock(Team::class);
$serviceMock->team = $team;
$serviceMock->config = $config;
$data = $serviceMock->matchByDomain('[EMAIL]');
$this->assertEquals(['test'], $data);
}
public function testBuildTaskSearchFields(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$service = new Service(
client: $client,
payloadBuilder: $payloadBuilder,
eventDispatcher: $eventDispatcher,
countriesMap: $countriesMap,
prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class)
);
$fields = $service->buildTaskSearchFields();
$expectedFields = ['Id', 'WhoId', 'WhatId', 'AccountId'];
$this->assertEquals($expectedFields, $fields);
}
public function testMapCrmObjects(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$service = new Service(
client: $client,
payloadBuilder: $payloadBuilder,
eventDispatcher: $eventDispatcher,
countriesMap: $countriesMap,
prospectPhotoPathService: $this->createMock(ProspectPhotoPathService::class),
);
$sampleTask = [
'WhoId' => '003sampleWhoId',
'AccountId' => 'sampleAccountId',
'WhatId' => 'sampleWhatId',
];
$activityData = $service->mapCrmObjects($sampleTask);
$expectedActivityData = [
'contact' => '003sampleWhoId',
'account' => 'sampleAccountId',
'opportunity' => 'sampleWhatId',
];
$this->assertEquals($expectedActivityData, $activityData);
}
public function testGetInstalledAppVersion(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
$queryIterator = $this->createMock(QueryIterator::class);
$queryIterator->expects($this->any())
->method('current')->willReturn([
'SubscriberPackageVersion' => [
'MajorVersion' => '1',
'MinorVersion' => '0',
'PatchVersion' => '1',
'BuildNumber' => '0',
],
]);
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('metadata')
->willReturn($queryIterator);
return $handler;
});
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods(array_diff(get_class_methods(Service::class), ['getInstalledAppVersion']))
->getMock();
$version = $serviceMock->getInstalledAppVersion();
$this->assertEquals('1010', $version);
}
public function testSyncProfiles(): void
{
$userToSearch = null;
$team = $this->createMock(Team::class);
$config = $this->createMockedConfiguration();
$config->expects($this->once())
->method('getId')
->willReturn(1);
$salesforceUser = [
'Email' => '[EMAIL]',
'UserPreferencesLightningExperiencePreferred' => true,
'CallCenterId' => '123',
'Id' => '456',
'ProfileId' => '789',
];
app()->bind(QueryBuilder::class, function () use ($userToSearch) {
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->expects($this->once())
->method('buildGetUsersQuery')
->with($userToSearch)
->willReturn('SELECT * FROM Users');
return $queryBuilder;
});
$queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('query')
->with('SELECT * FROM Users')
->willReturn($queryIterator);
return $handler;
});
$user = $this->createMock(User::class);
$user->expects($this->once())
->method('getId')
->willReturn(123);
$this->mockTeamRepository($team, $salesforceUser, $user);
$profileRepository = $this->createMock(ProfileRepository::class);
$profileRepository->expects($this->once())
->method('updateOrCreateProfile')
->with(
$user,
[
'crm_configuration_id' => 1,
'crm_provider_id' => '456',
],
[
'user_id' => 123,
'edition' => Profile::EDITION_LIGHTNING,
'has_external_cti' => true,
'crm_profile_id' => '789',
]
)
->willReturn(new Profile());
$this->app->instance(ProfileRepository::class, $profileRepository);
$serviceMock = $this->getServiceMock();
$serviceMock->team = $team;
$serviceMock->config = $config;
$result = $serviceMock->syncProfiles($userToSearch);
$this->assertNull($result);
}
public function testSyncProfilesEmailIsNull(): void
{
$userToSearch = $this->createMock(User::class);
$salesforceUser = [
'Email' => null,
];
app()->bind(QueryBuilder::class, function () {
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->expects($this->once())
->method('buildGetUsersQuery')
->with(null)
->willReturn('SELECT * FROM Users');
return $queryBuilder;
});
$queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->exactly(2))
->method('query')
->willReturn($queryIterator);
return $handler;
});
$team = $this->createMock(Team::class);
$user = $this->createMock(User::class);
$this->mockTeamRepository($team, $salesforceUser, $user, false);
$config = $this->createMock(Configuration::class);
$serviceMock = $this->getServiceMock();
$serviceMock->team = $team;
$serviceMock->config = $config;
$profile = $serviceMock->syncProfiles(null);
$this->assertNull($profile);
}
public function testSyncProfilesUserToSearchMatchesCurrentUser(): void
{
$userToSearch = $this->createMock(User::class);
$userToSearch->expects($this->once())
->method('getId')
->willReturn(123);
$team = $this->createMock(Team::class);
$config = $this->createMock(Configuration::class);
$config->expects($this->once())
->method('getId')
->willReturn(1);
$salesforceUser = [
'Email' => '[EMAIL]',
'UserPreferencesLightningExperiencePreferred' => true,
'CallCenterId' => '123',
'Id' => '456',
'ProfileId' => '789',
];
app()->bind(QueryBuilder::class, function () use ($userToSearch) {
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->expects($this->once())
->method('buildGetUsersQuery')
->with($userToSearch)
->willReturn('SELECT * FROM Users');
return $queryBuilder;
});
$queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults([$salesforceUser], 1, true, null));
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('query')
->with('SELECT * FROM Users')
->willReturn($queryIterator);
return $handler;
});
$user = $this->createMock(User::class);
$user->expects($this->exactly(2))
->method('getId')
->willReturn(123);
$this->mockTeamRepository($team, $salesforceUser, $user);
$profileRepository = $this->createMock(ProfileRepository::class);
$profileRepository->expects($this->once())
->method('updateOrCreateProfile')
->with(
$user,
[
'crm_configuration_id' => 1,
'crm_provider_id' => '456',
],
[
'user_id' => 123,
'edition' => Profile::EDITION_LIGHTNING,
'has_external_cti' => true,
'crm_profile_id' => '789',
]
)
->willReturn(new Profile());
$this->app->instance(ProfileRepository::class, $profileRepository);
$serviceMock = $this->getServiceMock();
$serviceMock->team = $team;
$serviceMock->config = $config;
$profile = $serviceMock->syncProfiles($userToSearch);
$this->assertInstanceOf(Profile::class, $profile);
}
public function testSyncProfilesWithCustomValidation(): void
{
$userToSearch = null;
$team = $this->createMock(Team::class);
$config = $this->createMockedConfiguration();
$config->expects($this->atLeastOnce()) // Changed from once() to atLeastOnce()
->method('getId')
->willReturn(1);
$salesforceUser = [
'Email' => '[EMAIL]',
'UserPreferencesLightningExperiencePreferred' => true,
'CallCenterId' => '123',
'Id' => '456',
'ProfileId' => '789',
'CustomField' => 'CustomValue',
];
$customRules = [
['field' => 'CustomField', 'value' => 'CustomValue'],
];
$this->mockQueryBuilderAndHandler($userToSearch, [$salesforceUser]);
$user = $this->createMock(User::class);
$user->expects($this->once())
->method('getId')
->willReturn(123);
$this->mockTeamRepository($team, $salesforceUser, $user, true, $customRules);
$this->mockProfileRepository($user);
$serviceMock = $this->getServiceMock();
$serviceMock->team = $team;
$serviceMock->config = $config;
$profile = $serviceMock->syncProfiles($userToSearch);
$this->assertNull($profile);
}
public function testSyncProfilesWithCustomValidationFailing(): void
{
$userToSearch = null;
$team = $this->createMock(Team::class);
$config = $this->createMockedConfiguration();
$salesforceUser = [
'Email' => '[EMAIL]',
'UserPreferencesLightningExperiencePreferred' => true,
'CallCenterId' => '123',
'Id' => '456',
'ProfileId' => '789',
'CustomField' => 'WrongValue',
];
$customRules = [
['field' => 'CustomField', 'value' => 'CustomValue'],
];
$this->mockQueryBuilderAndHandler($userToSearch, [$salesforceUser]);
$teamRepository = $this->getMockForAbstractClass(TeamRepository::class, [], '', false, true, true, ['findActiveTeamMemberByEmail', 'getTeamSetting']);
$teamSettings = $this->createMock(TeamSettings::class);
$teamSettings->method('getValueType')
->willReturn('array');
$teamSettings->method('getValue')
->willReturn(json_encode($customRules));
$teamRepository->expects($this->once())
->method('getTeamSetting')
->with($team, 'custom_profile_validation')
->willReturn($teamSettings);
app()->bind(TeamRepository::class, function () use ($teamRepository) {
return $teamRepository;
});
$profileRepository = $this->createMock(ProfileRepository::class);
$profileRepository->expects($this->never())
->method('updateOrCreateProfile');
$this->app->instance(ProfileRepository::class, $profileRepository);
$serviceMock = $this->getServiceMock();
$serviceMock->team = $team;
$serviceMock->config = $config;
$result = $serviceMock->syncProfiles($userToSearch);
$this->assertNull($result);
}
public function testGetContactRolesFromCrm(): void
{
$contactRoles = [
[
'Id' => '1',
'ContactId' => 'Contact1',
'OpportunityId' => 'Opportunity1',
'Opportunity' => ['OwnerId' => 'Owner1'],
'IsPrimary' => true,
'Role' => 'Decision Maker',
],
];
$expectedResponse = [
[
'id' => '1',
'contactId' => 'Contact1',
'opportunityId' => 'Opportunity1',
'ownerId' => 'Owner1',
'isPrimary' => true,
'role' => 'Decision Maker',
],
];
$this->bindQueryIterator($contactRoles);
$serviceMock = $this->getServiceMock();
$data = $serviceMock->getContactRolesFromCrm(now()->subDay());
$this->assertEquals($expectedResponse, $data);
}
public function testGetContactRolesFromCrmNoResult(): void
{
$this->bindQueryIterator([]);
$serviceMock = $this->getServiceMock();
$data = $serviceMock->getContactRolesFromCrm(now()->subDay());
$this->assertEquals([], $data);
}
public function testSyncContactRoles(): void
{
$contactRoles = [
[
'id' => '1',
'contactId' => 'Contact1',
'opportunityId' => 'Opportunity1',
'ownerId' => 'Owner1',
'isPrimary' => true,
'role' => 'Decision Maker',
],
];
app()->bind(ContactRoleRepository::class, function () {
$contactRoleRepository = $this->createMock(ContactRoleRepository::class);
$contactRoleRepository->expects($this->once())
->method('saveContactRoles');
return $contactRoleRepository;
});
$serviceMock = $this->getServiceMock([
'getContactRolesFromCrm',
'syncRemotelyDeletedContactRoles',
'syncContact',
'syncOpportunity',
]);
$config = $this->createMock(Configuration::class);
$hasMany = $this->createMock(HasManyExtended::class);
$hasMany->expects($this->exactly(2))
->method('where')
->willReturn($hasMany);
$hasMany->expects($this->exactly(2))
->method('first')
->willReturn(
$this->createMock(Contact::class),
$this->createMock(Opportunity::class)
);
$config->expects($this->once())
->method('contacts')
->willReturn($hasMany);
$config->expects($this->once())
->method('opportunities')
->willReturn($hasMany);
$serviceMock->config = $config;
$serviceMock->expects($this->once())
->method('getContactRolesFromCrm')
->willReturn($contactRoles);
$serviceMock->expects($this->once())
->method('syncRemotelyDeletedContactRoles');
$data = $serviceMock->syncContactRoles(now()->subDay());
$this->assertEquals(1, $data);
}
public function testSyncRemotelyDeletedContactRoles(): void
{
$contactRoles = [
[
'id' => '1',
'crm_provider_id' => '1',
],
];
app()->bind(QueryHandler::class, function () use ($contactRoles) {
$queryResults = new QueryResults($contactRoles, 1, true, null);
$handler = $this->createMock(QueryHandler::class);
$handler->method('queryDeleted')
->willReturn($queryResults);
return $handler;
});
app()->bind(ContactRoleRepository::class, function () {
$contactRoleRepository = $this->createMock(ContactRoleRepository::class);
$contactRoleRepository->expects($this->once())
->method('deleteContactRoles');
return $contactRoleRepository;
});
$serviceMock = $this->getServiceMock();
$serviceMock->team = $this->createMock(Team::class);
$data = $this->invokePrivateMethod('syncRemotelyDeletedContactRoles', $serviceMock, []);
$this->assertTrue($data);
}
private function bindQueryIterator(array $queryResult): void
{
/** @var Client $client */
$client = $this->createMock(Client::class);
$queryIterator = new QueryIterator(
$client,
new QueryResults($queryResult, 1, true, null)
);
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('query')
->willReturn($queryIterator);
return $handler;
});
}
public static function getOpportunitySortOrderDataProvider(): array
{
return [
'all open recently updated' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED, ['LastModifiedDate DESC', true]],
'all open recently created' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED, ['CreatedDate DESC', true]],
'all open oldest created' => [Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED, ['CreatedDate ASC', true]],
'all recently updated' => [Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED, ['LastModifiedDate DESC', false]],
'default' => ['unknown', ['LastModifiedDate DESC', true]],
];
}
private function createMockedConfiguration(): Configuration
{
$config = $this->createMock(Configuration::class);
$profilesRelation = $this->getMockBuilder(\Illuminate\Database\Eloquent\Relations\HasMany::class)
->disableOriginalConstructor()
->onlyMethods(['get'])
->addMethods(['where', 'first'])
->getMock();
$profilesRelation->method('where')->willReturnSelf();
$profilesRelation->method('get')->willReturn(collect([]));
$profilesRelation->method('first')->willReturn(null);
$config->method('profiles')->willReturn($profilesRelation);
return $config;
}
private function getServiceMock(array $onlyMethods = []): MockObject&Service
{
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$this->createMock(Client::class),
$this->createMock(PayloadBuilder::class),
$this->createMock(Dispatcher::class),
$this->createMock(CountriesMap::class),
$this->createMock(ProspectPhotoPathService::class),
])
->onlyMethods($onlyMethods)
->getMock();
$serviceMock->profile = $this->createMock(Profile::class);
return $serviceMock;
}
private function mockQueryBuilderAndHandler($userToSearch, $salesforceUsers): void
{
app()->bind(QueryBuilder::class, function () use ($userToSearch) {
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->expects($this->once())
->method('buildGetUsersQuery')
->with($userToSearch)
->willReturn('SELECT * FROM Users');
return $queryBuilder;
});
$queryIterator = new QueryIterator($this->createMock(Client::class), new QueryResults($salesforceUsers, count($salesforceUsers), true, null));
app()->bind(QueryHandler::class, function () use ($queryIterator) {
$handler = $this->createMock(QueryHandler::class);
$handler->expects($this->any())
->method('query')
->willReturn($queryIterator);
return $handler;
});
}
private function mockTeamRepository(
Team $team,
array $salesforceUser,
?User $user = null,
bool $userSearch = true,
array $customRules = []
): void {
$teamRepository = $this->getMockForAbstractClass(TeamRepository::class, [], '', false, true, true, ['findActiveTeamMemberByEmail', 'getTeamSetting']);
if ($userSearch) {
$teamRepository->expects($this->once())
->method('findActiveTeamMemberByEmail')
->with($team, $salesforceUser['Email'])
->willReturn($user);
}
$teamSettings = $this->createMock(TeamSettings::class);
$teamSettings->method('getValueType')
->willReturn('array');
$teamSettings->method('getValue')
->willReturn(json_encode($customRules));
$teamRepository->expects($this->once())
->method('getTeamSetting')
->with($team, 'custom_profile_validation')
->willReturn($teamSettings);
app()->bind(TeamRepository::class, function () use ($teamRepository) {
return $teamRepository;
});
}
private function mockProfileRepository(User $user): void
{
$profileRepository = $this->createMock(ProfileRepository::class);
$profileRepository->expects($this->once())
->method('updateOrCreateProfile')
->with(
$user,
[
'crm_configuration_id' => 1,
'crm_provider_id' => '456',
],
[
'user_id' => 123,
'edition' => Profile::EDITION_LIGHTNING,
'has_external_cti' => true,
'crm_profile_id' => '789',
]
)
->willReturn(new Profile());
$this->app->instance(ProfileRepository::class, $profileRepository);
}
public function testBuildEnhancedNoteDecodesHtmlEntities(): void
{
$service = $this->getServiceMock(['createRecord']);
$profile = new Profile();
$profile->setAttribute('crm_provider_id', 'owner-123');
$service->profile = $profile;
$service->expects($this->exactly(2))
->method('createRecord')
->willReturnOnConsecutiveCalls('note-id-123', 'link-id-456');
$bodyWithEntities = 'Welch's current challenges and Facebook's Club';
$result = $this->invokePrivateMethod('buildEnhancedNote', $service, [
'Test Title',
$bodyWithEntities,
'object-id-789',
]);
$this->assertEquals('note-id-123', $result);
}
public function testBuildEnhancedNoteSanitizesWithoutQuotes(): void
{
$service = $this->getServiceMock(['createRecord']);
$profile = new Profile();
$profile->setAttribute('crm_provider_id', 'owner-456');
$service->profile = $profile;
$service->expects($this->exactly(2))
->method('createRecord')
->willReturnCallback(function ($type, $data) {
if ($type === 'ContentNote') {
$decoded = base64_decode($data['Content']);
$this->assertStringContainsString("Welch's", $decoded);
$this->assertStringNotContainsString(''', $decoded);
$this->assertStringNotContainsString('&#039;', $decoded);
$this->assertStringContainsString('<script>', $decoded);
return 'note-id-456';
}
return 'link-id-789';
});
$bodyWithMixedContent = "Welch's and <script>alert('xss')</script>";
$result = $this->invokePrivateMethod('buildEnhancedNote', $service, [
'Test Title',
$bodyWithMixedContent,
'object-id-123',
]);
$this->assertEquals('note-id-456', $result);
}
public function testBuildEnhancedNoteConvertsLineBreaks(): void
{
$service = $this->getServiceMock(['createRecord']);
$profile = new Profile();
$profile->setAttribute('crm_provider_id', 'owner-789');
$service->profile = $profile;
$service->expects($this->exactly(2))
->method('createRecord')
->willReturnCallback(function ($type, $data) {
if ($type === 'ContentNote') {
$decoded = base64_decode($data['Content']);
$this->assertStringContainsString('<br>', $decoded);
$this->assertStringNotContainsString('<br />', $decoded);
return 'note-id-789';
}
return 'link-id-012';
});
$bodyWithLineBreaks = "Line 1\nLine 2\nLine 3";
$result = $this->invokePrivateMethod('buildEnhancedNote', $service, [
'Test Title',
$bodyWithLineBreaks,
'object-id-456',
]);
$this->assertEquals('note-id-789', $result);
}
public function testBuildEnhancedNoteHandlesComplexScenario(): void
{
$service = $this->getServiceMock(['createRecord']);
$profile = new Profile();
$profile->setAttribute('crm_provider_id', 'owner-complex');
$service->profile = $profile;
$service->expects($this->exactly(2))
->method('createRecord')
->willReturnCallback(function ($type, $data) {
if ($type === 'ContentNote') {
$decoded = base64_decode($data['Content']);
$this->assertStringContainsString("Welch's", $decoded);
$this->assertStringContainsString("Facebook's Club", $decoded);
$this->assertStringContainsString("Arctics'", $decoded);
$this->assertStringNotContainsString(''', $decoded);
$this->assertStringNotContainsString('&#039;', $decoded);
$this->assertStringContainsString('<br>', $decoded);
$this->assertStringContainsString('<', $decoded);
$this->assertStringContainsString('>', $decoded);
return 'note-complex';
}
return 'link-complex';
});
$complexBody = "Summary:\n---------\nThe call focused on understanding Welch's current challenges and exploring how Arctics' Revenue Growth Management solutions could support their strategic goals.\n\n• John SMith discussed his role as a category advisor for Google and Facebook's Club, emphasizing the importance of market research and advising on product assortment.\n• Madona introduced Arctics' Virtual Shoppers AI, which simulates consumer <behavior> to optimize pricing and promotional strategies.";
$result = $this->invokePrivateMethod('buildEnhancedNote', $service, [
'Jiminny Transcription Summary',
$complexBody,
'task-id-001',
]);
$this->assertEquals('note-complex', $result);
}
public function testSyncRemotelyDeletedObjectsWithErrorHandlingSuccess(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$prospectPhotoPathService,
])
->onlyMethods(['syncRemotelyDeletedObjects'])
->getMock();
// Mock team
$team = $this->createMock(Team::class);
$team->method('getUuid')->willReturn('team-uuid-123');
$serviceMock->team = $team;
// Expect syncRemotelyDeletedObjects to be called once and succeed
$serviceMock->expects($this->once())
->method('syncRemotelyDeletedObjects')
->with(\Jiminny\Enums\CrmObject::ACCOUNT);
// Call the protected method using reflection
$reflection = new \ReflectionClass($serviceMock);
$method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');
$method->setAccessible(true);
// Should not throw any exceptions
$method->invoke($serviceMock, \Jiminny\Enums\CrmObject::ACCOUNT);
$this->assertTrue(true); // Test completed successfully
}
public function testSyncRemotelyDeletedObjectsWithErrorHandlingFailure(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$prospectPhotoPathService,
])
->onlyMethods(['syncRemotelyDeletedObjects'])
->getMock();
// Mock team
$team = $this->createMock(Team::class);
$team->method('getUuid')->willReturn('team-uuid-456');
$serviceMock->team = $team;
// Mock logger to verify warning is logged
$logger = $this->createMock(\Psr\Log\LoggerInterface::class);
// Use reflection to set the protected logger property
$reflection = new \ReflectionClass($serviceMock);
$loggerProperty = $reflection->getProperty('logger');
$loggerProperty->setAccessible(true);
$loggerProperty->setValue($serviceMock, $logger);
$exception = new \Exception('Sync failed due to API error');
// Expect syncRemotelyDeletedObjects to throw an exception
$serviceMock->expects($this->once())
->method('syncRemotelyDeletedObjects')
->with(\Jiminny\Enums\CrmObject::CONTACT)
->willThrowException($exception);
// Expect warning to be logged with correct message and parameters
$logger->expects($this->once())
->method('warning')
->with(
'[Salesforce] Remotely deleted objects sync failed',
[
'objectType' => 'contact',
'teamId' => 'team-uuid-456',
'reason' => 'Sync failed due to API error',
]
);
// Call the protected method using reflection
$reflection = new \ReflectionClass($serviceMock);
$method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');
$method->setAccessible(true);
// Should not re-throw the exception, just log it
$method->invoke($serviceMock, \Jiminny\Enums\CrmObject::CONTACT);
$this->assertTrue(true); // Test completed successfully
}
public function testSyncRemotelyDeletedObjectsWithErrorHandlingWithLogParams(): void
{
$client = $this->createMock(Client::class);
$payloadBuilder = $this->createMock(PayloadBuilder::class);
$countriesMap = $this->createMock(CountriesMap::class);
$prospectPhotoPathService = $this->createMock(ProspectPhotoPathService::class);
$eventDispatcher = $this->createMock(Dispatcher::class);
/** @var Service&MockObject $serviceMock */
$serviceMock = $this->getMockBuilder(Service::class)
->setConstructorArgs([
$client,
$payloadBuilder,
$eventDispatcher,
$countriesMap,
$prospectPhotoPathService,
])
->onlyMethods(['syncRemotelyDeletedObjects'])
->getMock();
// Mock team
$team = $this->createMock(Team::class);
$team->method('getUuid')->willReturn('team-uuid-789');
$serviceMock->team = $team;
// Mock logger to verify warning is logged
$logger = $this->createMock(\Psr\Log\LoggerInterface::class);
// Use reflection to set the protected logger property
$loggerReflection = new \ReflectionClass($serviceMock);
$loggerProperty = $loggerReflection->getProperty('logger');
$loggerProperty->setAccessible(true);
$loggerProperty->setValue($serviceMock, $logger);
$exception = new \Exception('Network timeout');
// Expect syncRemotelyDeletedObjects to throw an exception
$serviceMock->expects($this->once())
->method('syncRemotelyDeletedObjects')
->with(\Jiminny\Enums\CrmObject::OPPORTUNITY)
->willThrowException($exception);
// Additional log parameters
$logParams = [
'syncType' => 'full',
'batchSize' => 100,
];
// Expect warning to be logged with merged parameters
$logger->expects($this->once())
->method('warning')
->with(
'[Salesforce] Remotely deleted objects sync failed',
[
'objectType' => 'opportunity',
'teamId' => 'team-uuid-789',
'reason' => 'Network timeout',
'syncType' => 'full',
'batchSize' => 100,
]
);
// Call the protected method using reflection
$reflection = new \ReflectionClass($serviceMock);
$method = $reflection->getMethod('syncRemotelyDeletedObjectsWithErrorHandling');
...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
70621
|
2537
|
3
|
2026-05-22T10:23:22.249237+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779445402249_m2.jpg...
|
PhpStorm
|
faVsco.js – ServiceTest.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
PhpStormVIewINavicarecodeKeractorFV faVsco.js?9 JY PhpStormVIewINavicarecodeKeractorFV faVsco.js?9 JY-20963-fix-import-on-deleted-entityProiect• m Zoom• MZoomBotZoomPhonecActivitvCrmFieldsResolverlest.onoeActivitvProviderclientlest.onp© ActivityProviderRegistryTest.php©) ActivityProviderServiceTest.php© CallDenormalizerRegistryTest.php© CrmOwnerResolverTest.php© MeetingBotServiceOnMeetingStartedi) MeeuingbotserviceonsharingDatales© MeetingBotServiceRequestRecordingic) PanicipantConsentservicelest.onoe) Panicioanisservicelest.onoc) ResponseValidationTraitTest.onoc) TrackRecordingFileSizeServiceTest.orc) TrackRecordingSizeEntorcerTest.onoc)ValidateEmitProspectEventTraitTest.M ActivitvProviders→AvatarN CalendanN Conference1m Crm• M Bullhorn• Close• D Helpers• m HubsnotD IntegrationApp• hlictenercPipedrivem CalocfarnoM Fields• 0 OpportunityMatcher# OpportunitySyncStrateay• C ProspectSearchStrateay• 0 ServiceTraits.© ClientTest.php© DecorateActivityTest.phpC DeleteObiectsTraitTest.ohp@ FieldDefinitionsTest.php@ GetActivitvFieldNameTest.ohn@ PavloadBuilderTest.ohp@ QuervBuilderTest.oho@ QuervHandlerTest.ohpc @uerviteratorTest.oho( QuervResultsTests nhn(c ServiceTect nhnC) SvncRatchRedicServiceTest nhnC RaceServiceTest nhnT DeleteObjectsTrait.php© DeleteOpportunityJob.phpT SoftDeletes.pngyMatchactvitycrmbata.ong© RecordSelector.php© Activity.php<?php85 л Vdeclare strict-cypes=l)9900namespace Tests Unit Services crm Salestorceruse•Run 'ServiceTest (PHPUnit)'SO- Debua "ServiceTest (PHPUnit)Run 'ServiceTest (PHPUnit)' with CoverageModity Run Contiquration…ateRelatedActivity: voidt...public function testFetchAndAssociateRelatedActivitySkipsForTaskBasedPlaybook: voidt...}100 )public function testFetchAndAssociateRelatedActivityReturnsNullForNonConference: void{...}225 D >public function testFetchAndAssociateRelatedActivityReturnsNullWhenNoStartTime: void{...}254 Dpublic function testFetchAndAssociateRelatedActivityReturnsNullWhenNoProspect: void{...}291 >public function testMatchExactlyByEmail: void{...}322 D>public function testMatchDomainFromEmail(): void{...}381 D >public function testBuildTaskSearchFieldsO: void{...}403 D ›public function testMapCrm0biects: void{...}435 D ›public function testGetInstalledAppVersion: void{...}4780>public function testSvncProfiles@: voidk...?553D>public function testSvncProfilesEmailTsNul1@: voidk...:597 )public function testSvncProfileslserToSearchMatchesCurrentUser@= voidf...}674675 D >public function testSyncProfilesWithCustomValidation(): void{...}717 D >public function testSyncProfilesWithCustomValidationFailing(): voidf...}771 D >803eetsnublic function tes+GetContac+RolesFromCrmo• void...?public function testGetContactRolesFromCrmNoResult: void{...}814815 D >public function testSyncContactRoles(): void{...}877 D >public function testSyncRemotelyDeletedContactRoles: void{...}The Hunsnell nluain hac heon denrecated. If vou're not writing in Hungarian vou can cafolv uninctall Huncnoll witiout affectina the dictionarios for other lanquades (todav 10-19)= custom.log= laravel.log4 SF jiminny@localhost]4 HS_local [jiminny@localhost]& console [PROD]& console [EU]tii crm_configurations [EU] X A console [STAGING]1rowv-OU+Y- WHERE id = 320AD idMuuid (UUID)No team_ 1dIo notifiable user 1d! providerI edition(• instanceis sandboxlu versionШ installed_app_version! sync_metadatasync obiectsT auto sync activityI crm provider id— M crm base un1O last_synced_at#leads_synced_atI accounts_synced_atlu contacts sunced atШopportunities_synced_atIu contact roles svncedatiover_quota_at1 api disabled_ at1• settingsTx: AutovDDLCSVL8=•URDER BY32046ea38f7-c940-395d-9c79-0329d30e9ab7salesforceenterpriseswesd50.00180100D20000008Mdb0EAChttos:binsolutions.mv.salesforce.2026-03-19 08:19:242026-05-22 09:48:212026-05-22 09:48:312026-05-22 09:48:432026-05-22 09:48:292026-05-22 09:48:40<null>enulls""opportunity sync_per1od":"5m" "oppopportunity assianment ruleonen-recentlv-uodated! opportunity_max_value250000000.01!opportunity_max_age5779I opportunity valve field id! trigger assignment rulesstore transcrintU softohone override prospect :In defaul+ curnencvI created at2024-11-26 09:21:15I updated at2026-05-22 09:48:43100% 2. Fri 22 May 13:23:22AskJiminnyReportActivityServiceTest vCascadeInvestigate Foreign Ke+O •Give me the lira ticket and the descriptionJira Ticket:JY-XXXXX: Fix foreign key violation when matching activities to deleted Salesforce opportunitiesWhen an opportunity is deleted in Salesforce and the local deletion job runs, activities that belonged to that opportunity arematched via the RematchActivity0nCrmObjectDetach listener. During this rematch, the system attempts to re-link thendleObjectDeletion (because IsDeleted = truel)The deleted opportunity's ID is then used in Activity: :updateActivityCrmData to set activity.opportunity_id,causina a foreian key violation because the opportunity no longer exists in the databaseThe FixAdded checks in importLead. importAccount. importOpportunity. and importContact methods to return null if theentty was deleted oy hand leudi ectbeletionuphoschas→nano levo ectvelecion sopportunaty, scruvata"if (Sopportunity->trashed)) {return null:return Sopportunity.This orevents the foreian kev constraint violation bv ensurina deleted entities are not returned from the svnc process• Activities will no longer fail with foreian key violations when rematching after opportunity deletion• The rematch process will aracefullv handle deleted Salesforce records bv returnina null instead of deleted entitv IDs• This affects all Salesforce CRM entity types: Lead, Account, Opportunity, and ContactFiles Chanaed;• app/Services/Crm/Salesforce/Service.oho lines 1087-1089. 1224-1226. 1436-1438. 1604-1606)Ask anvthina (&4-L)« Code SWF-1.6•0 1W Windsurf Team1-1.UTE.8Po 4 spaces...
|
NULL
|
6509876537626341160
|
NULL
|
click
|
ocr
|
NULL
|
PhpStormVIewINavicarecodeKeractorFV faVsco.js?9 JY PhpStormVIewINavicarecodeKeractorFV faVsco.js?9 JY-20963-fix-import-on-deleted-entityProiect• m Zoom• MZoomBotZoomPhonecActivitvCrmFieldsResolverlest.onoeActivitvProviderclientlest.onp© ActivityProviderRegistryTest.php©) ActivityProviderServiceTest.php© CallDenormalizerRegistryTest.php© CrmOwnerResolverTest.php© MeetingBotServiceOnMeetingStartedi) MeeuingbotserviceonsharingDatales© MeetingBotServiceRequestRecordingic) PanicipantConsentservicelest.onoe) Panicioanisservicelest.onoc) ResponseValidationTraitTest.onoc) TrackRecordingFileSizeServiceTest.orc) TrackRecordingSizeEntorcerTest.onoc)ValidateEmitProspectEventTraitTest.M ActivitvProviders→AvatarN CalendanN Conference1m Crm• M Bullhorn• Close• D Helpers• m HubsnotD IntegrationApp• hlictenercPipedrivem CalocfarnoM Fields• 0 OpportunityMatcher# OpportunitySyncStrateay• C ProspectSearchStrateay• 0 ServiceTraits.© ClientTest.php© DecorateActivityTest.phpC DeleteObiectsTraitTest.ohp@ FieldDefinitionsTest.php@ GetActivitvFieldNameTest.ohn@ PavloadBuilderTest.ohp@ QuervBuilderTest.oho@ QuervHandlerTest.ohpc @uerviteratorTest.oho( QuervResultsTests nhn(c ServiceTect nhnC) SvncRatchRedicServiceTest nhnC RaceServiceTest nhnT DeleteObjectsTrait.php© DeleteOpportunityJob.phpT SoftDeletes.pngyMatchactvitycrmbata.ong© RecordSelector.php© Activity.php<?php85 л Vdeclare strict-cypes=l)9900namespace Tests Unit Services crm Salestorceruse•Run 'ServiceTest (PHPUnit)'SO- Debua "ServiceTest (PHPUnit)Run 'ServiceTest (PHPUnit)' with CoverageModity Run Contiquration…ateRelatedActivity: voidt...public function testFetchAndAssociateRelatedActivitySkipsForTaskBasedPlaybook: voidt...}100 )public function testFetchAndAssociateRelatedActivityReturnsNullForNonConference: void{...}225 D >public function testFetchAndAssociateRelatedActivityReturnsNullWhenNoStartTime: void{...}254 Dpublic function testFetchAndAssociateRelatedActivityReturnsNullWhenNoProspect: void{...}291 >public function testMatchExactlyByEmail: void{...}322 D>public function testMatchDomainFromEmail(): void{...}381 D >public function testBuildTaskSearchFieldsO: void{...}403 D ›public function testMapCrm0biects: void{...}435 D ›public function testGetInstalledAppVersion: void{...}4780>public function testSvncProfiles@: voidk...?553D>public function testSvncProfilesEmailTsNul1@: voidk...:597 )public function testSvncProfileslserToSearchMatchesCurrentUser@= voidf...}674675 D >public function testSyncProfilesWithCustomValidation(): void{...}717 D >public function testSyncProfilesWithCustomValidationFailing(): voidf...}771 D >803eetsnublic function tes+GetContac+RolesFromCrmo• void...?public function testGetContactRolesFromCrmNoResult: void{...}814815 D >public function testSyncContactRoles(): void{...}877 D >public function testSyncRemotelyDeletedContactRoles: void{...}The Hunsnell nluain hac heon denrecated. If vou're not writing in Hungarian vou can cafolv uninctall Huncnoll witiout affectina the dictionarios for other lanquades (todav 10-19)= custom.log= laravel.log4 SF jiminny@localhost]4 HS_local [jiminny@localhost]& console [PROD]& console [EU]tii crm_configurations [EU] X A console [STAGING]1rowv-OU+Y- WHERE id = 320AD idMuuid (UUID)No team_ 1dIo notifiable user 1d! providerI edition(• instanceis sandboxlu versionШ installed_app_version! sync_metadatasync obiectsT auto sync activityI crm provider id— M crm base un1O last_synced_at#leads_synced_atI accounts_synced_atlu contacts sunced atШopportunities_synced_atIu contact roles svncedatiover_quota_at1 api disabled_ at1• settingsTx: AutovDDLCSVL8=•URDER BY32046ea38f7-c940-395d-9c79-0329d30e9ab7salesforceenterpriseswesd50.00180100D20000008Mdb0EAChttos:binsolutions.mv.salesforce.2026-03-19 08:19:242026-05-22 09:48:212026-05-22 09:48:312026-05-22 09:48:432026-05-22 09:48:292026-05-22 09:48:40<null>enulls""opportunity sync_per1od":"5m" "oppopportunity assianment ruleonen-recentlv-uodated! opportunity_max_value250000000.01!opportunity_max_age5779I opportunity valve field id! trigger assignment rulesstore transcrintU softohone override prospect :In defaul+ curnencvI created at2024-11-26 09:21:15I updated at2026-05-22 09:48:43100% 2. Fri 22 May 13:23:22AskJiminnyReportActivityServiceTest vCascadeInvestigate Foreign Ke+O •Give me the lira ticket and the descriptionJira Ticket:JY-XXXXX: Fix foreign key violation when matching activities to deleted Salesforce opportunitiesWhen an opportunity is deleted in Salesforce and the local deletion job runs, activities that belonged to that opportunity arematched via the RematchActivity0nCrmObjectDetach listener. During this rematch, the system attempts to re-link thendleObjectDeletion (because IsDeleted = truel)The deleted opportunity's ID is then used in Activity: :updateActivityCrmData to set activity.opportunity_id,causina a foreian key violation because the opportunity no longer exists in the databaseThe FixAdded checks in importLead. importAccount. importOpportunity. and importContact methods to return null if theentty was deleted oy hand leudi ectbeletionuphoschas→nano levo ectvelecion sopportunaty, scruvata"if (Sopportunity->trashed)) {return null:return Sopportunity.This orevents the foreian kev constraint violation bv ensurina deleted entities are not returned from the svnc process• Activities will no longer fail with foreian key violations when rematching after opportunity deletion• The rematch process will aracefullv handle deleted Salesforce records bv returnina null instead of deleted entitv IDs• This affects all Salesforce CRM entity types: Lead, Account, Opportunity, and ContactFiles Chanaed;• app/Services/Crm/Salesforce/Service.oho lines 1087-1089. 1224-1226. 1436-1438. 1604-1606)Ask anvthina (&4-L)« Code SWF-1.6•0 1W Windsurf Team1-1.UTE.8Po 4 spaces...
|
70619
|
NULL
|
NULL
|
NULL
|
|
70620
|
2536
|
3
|
2026-05-22T10:23:19.861130+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779445399861_m1.jpg...
|
PhpStorm
|
faVsco.js – ServiceTest.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
SlackFile EditViewGoHistoryWindowHelpAPP (IDOCKERD SlackFile EditViewGoHistoryWindowHelpAPP (IDOCKERDEV (-zsh)О 882APP (-zsh)tests/Unit/Services/Mail/0ffice/EmailApiClientTest.phptests/Unit/Services/Mail/TextRelayServiceTest.phptests/Unit/Services/RecallAI/RecallAIServiceTest.php79 files changed, 4254 insertions(+), 2020 deletions(-)create mode 100644•github/claude-reviewer/no-ticket-warning.txtcreate mode.github/claude-reviewer/scripts/fetch-jira-context.mjsdelete mode100644 app/Component/ES/Processor/BatchStatusInterface.phpcreate mode100644 app/Component/ES/Processor/RedisEntityExtractor.phpdelete mode100644 app/Component/ES/Processor/Traits/WorkerIdTrait.php100644 app/Component/ES/QueuePriorityEnum.phpcreate mode100644 front-end/src/components/DealInsights/Modals/__tests__/AskAnythingModacreatemode 100644 front-end/src/components/shared/AskAnything/__tests__/__snapshots__/Asdeletemode100644 front-end/src/components/shared/AskAnything/__tests__/_snapshots_/Ascreatemode100644 tests/Unit/Component/ES/Processor/RedisEntityExtractorTest.phpdelete mode100644 tests/Unit/Component/ES/Processor/Traits/WorkerIdTraitTest.phpcreate mode100644 tests/Unit/Component/ES/QueuePriorityEnumTest.phpcreate mode100644 tests/Unit/Notifications/Activities/SmsReceivedTest.phpcreate mode100644 tests/Unit/Services/Mail/TextRelayServiceTest.phplukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ co -b JY-20963-fix-imporSwitched to a new branch 'JY-20963-fix-import-on-deleted-entity'lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/Jiminny/app (JY-20963-fix-import-on-deleted-ent|docker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix--config=.php-cs-fixer.dist.phWhat's next:Try Docker Debug forseamless, persistent debugging tools in any container or image →Learn moreat [URL_WITH_CREDENTIALS] ~/jiminny/app (JY-20963-fix-import-on-deleted-ent)docker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.phPHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminski15 umconafi amp cont-fbteo-di5t. phPHP runtime: 8.3.30Running analysis on 7 cores with 10 files per process.Parallel runner is an experimental feature and may be unstable, use it at your own risk.Loaded config default from".php-cs-fixer.dist.php".5691/5691 [8100%Fixed 0 of 5691 files in 96.077 seconds, 67.00 MB memory usedWhat's next:Try Docker Debug for seamless, persistent debugging tools in anycontainer orimage →Learn moreat [URL_WITH_CREDENTIALS] review requested by @VasilVasilev#12120 Jy 20842 add deleteevents for es by @Vasil Vasilev9 commits • 14 files changedJIRA: JY-20842Changes:• Introduce events and eventlisteners that allow sending"document deleted" events for ESconsumption - both Hard and Softdeleted documents will beremoved.• Loa...Show morejiminny/appAdded by Toast for GitHubMessage Toast+Aa..•...
|
NULL
|
7187173416065921629
|
NULL
|
click
|
ocr
|
NULL
|
SlackFile EditViewGoHistoryWindowHelpAPP (IDOCKERD SlackFile EditViewGoHistoryWindowHelpAPP (IDOCKERDEV (-zsh)О 882APP (-zsh)tests/Unit/Services/Mail/0ffice/EmailApiClientTest.phptests/Unit/Services/Mail/TextRelayServiceTest.phptests/Unit/Services/RecallAI/RecallAIServiceTest.php79 files changed, 4254 insertions(+), 2020 deletions(-)create mode 100644•github/claude-reviewer/no-ticket-warning.txtcreate mode.github/claude-reviewer/scripts/fetch-jira-context.mjsdelete mode100644 app/Component/ES/Processor/BatchStatusInterface.phpcreate mode100644 app/Component/ES/Processor/RedisEntityExtractor.phpdelete mode100644 app/Component/ES/Processor/Traits/WorkerIdTrait.php100644 app/Component/ES/QueuePriorityEnum.phpcreate mode100644 front-end/src/components/DealInsights/Modals/__tests__/AskAnythingModacreatemode 100644 front-end/src/components/shared/AskAnything/__tests__/__snapshots__/Asdeletemode100644 front-end/src/components/shared/AskAnything/__tests__/_snapshots_/Ascreatemode100644 tests/Unit/Component/ES/Processor/RedisEntityExtractorTest.phpdelete mode100644 tests/Unit/Component/ES/Processor/Traits/WorkerIdTraitTest.phpcreate mode100644 tests/Unit/Component/ES/QueuePriorityEnumTest.phpcreate mode100644 tests/Unit/Notifications/Activities/SmsReceivedTest.phpcreate mode100644 tests/Unit/Services/Mail/TextRelayServiceTest.phplukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ co -b JY-20963-fix-imporSwitched to a new branch 'JY-20963-fix-import-on-deleted-entity'lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/Jiminny/app (JY-20963-fix-import-on-deleted-ent|docker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix--config=.php-cs-fixer.dist.phWhat's next:Try Docker Debug forseamless, persistent debugging tools in any container or image →Learn moreat [URL_WITH_CREDENTIALS] ~/jiminny/app (JY-20963-fix-import-on-deleted-ent)docker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.phPHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminski15 umconafi amp cont-fbteo-di5t. phPHP runtime: 8.3.30Running analysis on 7 cores with 10 files per process.Parallel runner is an experimental feature and may be unstable, use it at your own risk.Loaded config default from".php-cs-fixer.dist.php".5691/5691 [8100%Fixed 0 of 5691 files in 96.077 seconds, 67.00 MB memory usedWhat's next:Try Docker Debug for seamless, persistent debugging tools in anycontainer orimage →Learn moreat [URL_WITH_CREDENTIALS] review requested by @VasilVasilev#12120 Jy 20842 add deleteevents for es by @Vasil Vasilev9 commits • 14 files changedJIRA: JY-20842Changes:• Introduce events and eventlisteners that allow sending"document deleted" events for ESconsumption - both Hard and Softdeleted documents will beremoved.• Loa...Show morejiminny/appAdded by Toast for GitHubMessage Toast+Aa..•...
|
70617
|
NULL
|
NULL
|
NULL
|
|
70619
|
2537
|
2
|
2026-05-22T10:23:18.530684+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779445398530_m2.jpg...
|
PhpStorm
|
faVsco.js – ServiceTest.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
PhostormVIeWINavicarecodeFV faVsco.js°9 JY-20963-f PhostormVIeWINavicarecodeFV faVsco.js°9 JY-20963-fix-imProiect v• m Zoom• MZoomBotZoomPhonecActivitvCrmFieldsResolverlest.onoeActivitvProviderclientlest.onp© ActivityProviderRegistryTest.php© ActivityProviderServiceTest.php© CallDenormalizerRegistryTest.phpg crmownerkesolverlest.onpc) Meeuingbotserviceonmeeuingstaned) MeeuingbotserviceonsharingDatalesc) Meeungbotservicekequestkecoraingc) PanicipantConsencservicelesc.onoe) Panicioanisservicelest.onoc) ResponseValidationTraitTest.onoc) TrackRecordingFileSizeServiceTest.orc) TrackRecordingSizeEntorcerTest.onoc)ValidateEmitProspectEventTraitTest.M ActivitvProviders•AvatarN Calendan• M Bullhorn• Close• m HubsnotD IntegrationApp• hlictenerc_ Pipearive• U rielas• 0 OpportunityMatcher# OpportunitySyncStrateay• ProspectSearchStrateay• 0 ServiceTraits.© ClientTest.php© DecorateActivityTest.phpC DeleteObiectsTraitTest.ohp@ FieldDefinitionsTest.php@ GetActivitvFieldNameTest.ohn@ PavloadBuilderTest.ohp@ QuervBuilderTest.oho@ QuervHandlerTest.ohnc @uerviteratorTest.oho( QuervPesultsTests nhn(c ServiceTect nhnC) SvncRatchRedicServiceTest nhnC RaceServiceTest nhnT DeleteObjectsTrait.phpc) [EMAIL]© RecordSelector.phpActivity.phpk?phpdeclare strict-cypes=l)namespace Tests Un1t Services crm Salestorcenuse ...40Dclass Servicelest extends Testcase100 )225 D >254 D291 >322 D>381 D >405 0>435 D >4780>5525530>597 D674675 D3717 D771 D>eets815 D >877 D>use TestPrivatelethod:public function testFetchAndAssociateRelatedActivityO: voidt...}public function testFetchAndAssociateRelatedActivitySkipsForTaskBasedPlaybook: voidt...}public function testFetchAndAssociateRelatedActivityReturnsNullForNonConference: void{...hpublic function testFetchAndAssociateRelatedActivityReturnsNullWhenNoStartTime: void{...}public function testFetchAndAssociateRelatedActivityReturnsNullWhenNoProspect: void{...}public function testMatchExactlyByEmail: void{...}public function testMatchDomainFromEmail(): void{...}public function testBuildTaskSearchFieldsO: void{...}public function testMapCrm0biects: void{...}public function testGetInstalledAppVersionO: void{...}public function testSvncProfiles@: voidk...?oublic function testSvncProfilesemaiuisiullo: void...apublic function testSvncProfileslserToSearchMatchesCurrentUser(@: void!...}public function testSyncProfilesWithCustomValidation(): void{...}public function testSyncProfilesWithCustomValidationFailing(): voidf...}nublic function tes+GetContac+RolesFromCrmo• void...?public function testGetContactRolesFromCrmNoResult: void(...}public function testSyncContactRoles(): void{...}public function testSyncRemotelyDeletedContactRoles: void{...}The Hunsnell nluain hac heon denrecated. If vou're not writing in Hungarian vou can cafolv uninctalctina the dictionarios for other lanquades (todav 10-19)= custom.log= laravel.log4 SF jiminny@localhost]4 HS_local [jiminny@localhost]& console [PROD]& console [EU]tii crm_configurations [EU] X A console [STAGING]1row vзOU+Tx: AutovDDLY- WHERE id = 320AD idMuuid (UUID)No team_ 1dIo notifiable user 1d! provider1 edition(• instanceis sandboxlu versionШ installed_app_version! sync_metadatasync obiectsT auto sync activityI crm provider iderm base urlO last_synced_at! leads_synced_atI accounts synced_ atlu contacts sunced atШopportunities_synced_atIu contact roles svncedati( over_quota_at1 api disabled_ at1• settings=•URDER BY32046ea38f7-c940-395d-9c79-0329d30e9ab7salesforceenterpriseswesd50.00180100D20000008Mdb0EAChttos:binsolutions.mv.salesforce…2026-03-19 08:19:242026-05-22 09:48:212026-05-22 09:48:312026-05-22 09:48:432026-05-22 09:48:292026-05-22 09:48:40<null>enulls""opportunity sync_per1od": "5m". "oppopportunity assianment ruleonen-recentlv-uodated! opportunity_max_value250000000.01!opportunity_max_age5772I opportunity valve field id! trigger assignment rulesstore transcrintU softohone override prospect :In defaul+ curnencvI created at2024-11-26 09:21:15I updated at2026-05-22 09:48:43100% 2. Fri 22 May 13:23:17AskJiminnyReportActivityServiceTest vCascadeInvestigate Foreign Ke+O •Give me the lira ticket and the descriptionJira Ticket:JY-XXXXX: Fix foreign key violation when matching activities to deleted Salesforce opportunitiesWhen an opportunity is deleted in Salesforce and the local deletion job runs, activities that belonged to that opportunity arematched via the RematchActivity0nCrmObjectDetach listener. During this rematch, the system attempts to re-link thendleObjectDeletion (because IsDeleted = truel)The deleted opportunity's ID is then used in Activity: :updateActivityCrmData to set activity.opportunity_id,causina a foreian key violation because the opportunity no longer exists in the databaseThe FixAdded checks in importLead. importAccount. importOpportunity. and importContact methods to return null if theentty was deleted oy hand leudiectbeletionuphoschas→nano levo ectvelecion sopportunaty, scruvata"if (Sopportunity->trashed)) {return null:return Sopportunity.This orevents the foreian kev constraint violation bv ensurina deleted entities are not returned from the svnc process• Activities will no longer fail with foreian key violations when rematching after opportunity deletion• The rematch process will aracefullv handle deleted Salesforce records bv returnina null instead of deleted entitv IDs• This affects all Salesforce CRM entity types: Lead, Account, Opportunity, and ContactFiles Chanaed;• app/Services/Crm/Salesforce/Service.oho lines 1087-1089. 1224-1226. 1436-1438. 1604-1606)Ask anvthina (&4-L)« Code SWF-1.6•0 1W Windsurf Team1-1.UTE.8io 4 spaces...
|
NULL
|
1665337897612215822
|
NULL
|
visual_change
|
ocr
|
NULL
|
PhostormVIeWINavicarecodeFV faVsco.js°9 JY-20963-f PhostormVIeWINavicarecodeFV faVsco.js°9 JY-20963-fix-imProiect v• m Zoom• MZoomBotZoomPhonecActivitvCrmFieldsResolverlest.onoeActivitvProviderclientlest.onp© ActivityProviderRegistryTest.php© ActivityProviderServiceTest.php© CallDenormalizerRegistryTest.phpg crmownerkesolverlest.onpc) Meeuingbotserviceonmeeuingstaned) MeeuingbotserviceonsharingDatalesc) Meeungbotservicekequestkecoraingc) PanicipantConsencservicelesc.onoe) Panicioanisservicelest.onoc) ResponseValidationTraitTest.onoc) TrackRecordingFileSizeServiceTest.orc) TrackRecordingSizeEntorcerTest.onoc)ValidateEmitProspectEventTraitTest.M ActivitvProviders•AvatarN Calendan• M Bullhorn• Close• m HubsnotD IntegrationApp• hlictenerc_ Pipearive• U rielas• 0 OpportunityMatcher# OpportunitySyncStrateay• ProspectSearchStrateay• 0 ServiceTraits.© ClientTest.php© DecorateActivityTest.phpC DeleteObiectsTraitTest.ohp@ FieldDefinitionsTest.php@ GetActivitvFieldNameTest.ohn@ PavloadBuilderTest.ohp@ QuervBuilderTest.oho@ QuervHandlerTest.ohnc @uerviteratorTest.oho( QuervPesultsTests nhn(c ServiceTect nhnC) SvncRatchRedicServiceTest nhnC RaceServiceTest nhnT DeleteObjectsTrait.phpc) [EMAIL]© RecordSelector.phpActivity.phpk?phpdeclare strict-cypes=l)namespace Tests Un1t Services crm Salestorcenuse ...40Dclass Servicelest extends Testcase100 )225 D >254 D291 >322 D>381 D >405 0>435 D >4780>5525530>597 D674675 D3717 D771 D>eets815 D >877 D>use TestPrivatelethod:public function testFetchAndAssociateRelatedActivityO: voidt...}public function testFetchAndAssociateRelatedActivitySkipsForTaskBasedPlaybook: voidt...}public function testFetchAndAssociateRelatedActivityReturnsNullForNonConference: void{...hpublic function testFetchAndAssociateRelatedActivityReturnsNullWhenNoStartTime: void{...}public function testFetchAndAssociateRelatedActivityReturnsNullWhenNoProspect: void{...}public function testMatchExactlyByEmail: void{...}public function testMatchDomainFromEmail(): void{...}public function testBuildTaskSearchFieldsO: void{...}public function testMapCrm0biects: void{...}public function testGetInstalledAppVersionO: void{...}public function testSvncProfiles@: voidk...?oublic function testSvncProfilesemaiuisiullo: void...apublic function testSvncProfileslserToSearchMatchesCurrentUser(@: void!...}public function testSyncProfilesWithCustomValidation(): void{...}public function testSyncProfilesWithCustomValidationFailing(): voidf...}nublic function tes+GetContac+RolesFromCrmo• void...?public function testGetContactRolesFromCrmNoResult: void(...}public function testSyncContactRoles(): void{...}public function testSyncRemotelyDeletedContactRoles: void{...}The Hunsnell nluain hac heon denrecated. If vou're not writing in Hungarian vou can cafolv uninctalctina the dictionarios for other lanquades (todav 10-19)= custom.log= laravel.log4 SF jiminny@localhost]4 HS_local [jiminny@localhost]& console [PROD]& console [EU]tii crm_configurations [EU] X A console [STAGING]1row vзOU+Tx: AutovDDLY- WHERE id = 320AD idMuuid (UUID)No team_ 1dIo notifiable user 1d! provider1 edition(• instanceis sandboxlu versionШ installed_app_version! sync_metadatasync obiectsT auto sync activityI crm provider iderm base urlO last_synced_at! leads_synced_atI accounts synced_ atlu contacts sunced atШopportunities_synced_atIu contact roles svncedati( over_quota_at1 api disabled_ at1• settings=•URDER BY32046ea38f7-c940-395d-9c79-0329d30e9ab7salesforceenterpriseswesd50.00180100D20000008Mdb0EAChttos:binsolutions.mv.salesforce…2026-03-19 08:19:242026-05-22 09:48:212026-05-22 09:48:312026-05-22 09:48:432026-05-22 09:48:292026-05-22 09:48:40<null>enulls""opportunity sync_per1od": "5m". "oppopportunity assianment ruleonen-recentlv-uodated! opportunity_max_value250000000.01!opportunity_max_age5772I opportunity valve field id! trigger assignment rulesstore transcrintU softohone override prospect :In defaul+ curnencvI created at2024-11-26 09:21:15I updated at2026-05-22 09:48:43100% 2. Fri 22 May 13:23:17AskJiminnyReportActivityServiceTest vCascadeInvestigate Foreign Ke+O •Give me the lira ticket and the descriptionJira Ticket:JY-XXXXX: Fix foreign key violation when matching activities to deleted Salesforce opportunitiesWhen an opportunity is deleted in Salesforce and the local deletion job runs, activities that belonged to that opportunity arematched via the RematchActivity0nCrmObjectDetach listener. During this rematch, the system attempts to re-link thendleObjectDeletion (because IsDeleted = truel)The deleted opportunity's ID is then used in Activity: :updateActivityCrmData to set activity.opportunity_id,causina a foreian key violation because the opportunity no longer exists in the databaseThe FixAdded checks in importLead. importAccount. importOpportunity. and importContact methods to return null if theentty was deleted oy hand leudiectbeletionuphoschas→nano levo ectvelecion sopportunaty, scruvata"if (Sopportunity->trashed)) {return null:return Sopportunity.This orevents the foreian kev constraint violation bv ensurina deleted entities are not returned from the svnc process• Activities will no longer fail with foreian key violations when rematching after opportunity deletion• The rematch process will aracefullv handle deleted Salesforce records bv returnina null instead of deleted entitv IDs• This affects all Salesforce CRM entity types: Lead, Account, Opportunity, and ContactFiles Chanaed;• app/Services/Crm/Salesforce/Service.oho lines 1087-1089. 1224-1226. 1436-1438. 1604-1606)Ask anvthina (&4-L)« Code SWF-1.6•0 1W Windsurf Team1-1.UTE.8io 4 spaces...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
70618
|
2537
|
1
|
2026-05-22T10:23:15.311977+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-22/1779 /Users/lukas/.screenpipe/data/data/2026-05-22/1779445395311_m2.jpg...
|
PhpStorm
|
faVsco.js – Service.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
PhostormFV faVsco.jsVIewINavicarecodeKeractor°9 JY PhostormFV faVsco.jsVIewINavicarecodeKeractor°9 JY-20963-fix-im100% 52• Fri 22 May 13:23:14AskJiminnyReportActivityServiceTest vo [EMAIL] DeleteObiectstralt.pnp© DeleteOpportunityJob.phpT SoftDeletes.php= custom.log= laravel.log« SF jiminny@localhost]4 HS_local [jiminny@localhost]& console [PROD]& console [EU]CascadeMatcnActviycmmbata.png© CrmActivityService.php© RecordSelector.php© Activity.phpclass service excenas baseservice Inplementstii crm_configurations [EU] X A console [STAGING]411 4,130 v.3 V.21 ^1rowv 5OU+Y- WHERE id = 320Investigate Foreign KeTx: AutovDDLCSVL8=•URDER BY+O •c syncbatchkealsservice.ongD Traitsbaseclient.ono© BaseService.phpc) cachedcrmServiceDecorator.phpc) countrycodeResolver.phpCrmActivityProviderIntegratedEventListe 1576c) crmActivitvService.ohpc) crmcontiqurationSettinasService.oho© CrmobjectsResolver.php© DetaultProspectSearchStrategy.php© EmailHelper.php© FindsProspectinterface.php1571110/4157315741575157815861582C)LavoutManager.oho© MatchDomainByEmailInterface.php(c) OnnortunitvActivitvMatcher.nhnl© OpportunitySyncStrategyinterface.php© OpportunitySyncStrategyResolver.php1588© ProspectSearchScope.php© ProspectSearchStrategyFactory.php159€© ProspectSearchStrategyInterface.phpProviderkegistry.ong1592a RecordSelector.php* ResolveCompanyNameByEmailTrait.php 1594© TimePerioditerator.php> C Import1596> C Internal0 Kiosk1598v @ AutomatedReports1599© ActivityTypeService.php1600© Ask.JiminnvReportActivitvService.pho 1601@ AutomatedReportsCallbackService.ph 1602C) AutomatedReportsService.oho11603C) DealStadesService.oho1604C) RecioientsService.oho11685E) ReportSort.oho1606E) RenortSortDirection.oh11607(C) KioskService.oho1608> M Mail.16091M MeetinaGeneratorM NotificationI1611MoAutha1612M Plavbookc1613Tm PocalllA1614(D SecurityM StrateayM Streamine166A GU$profile = Sthis->getOwnerProfile( ownerld: $crmData('OwnerId'] ?? null):$data =team_id'account id'user 10name→ Sch1S->ceam->10,→ Saccount->10 !: nuLl.→ Sprotile:->user10,=> ScrmDatal 'OwnerId'] 22 nulll=> (ScrmData['Name'] ?2 null) !== null ? mb strimwidth(ScrmDatal'Nan=> ($crmData['Title'] ?? null) !== null ? mb_strimwidth($crmData['Ti= ScrmDatal'Emant' ?? nulu == nult ? mb strimwidth ScrmbatalEm= Scountrvcode.=> SparsedNumber['phone'] 22 null.mobnle nhone=> SmobileNumben'photo nath' => Sthis->orosnectPhotoPathService->get0rGeneratePhotoPathdcrmConfiguration: $this->config,crmProviderid: ScrmDatal'Id'],modelType: Contact::class,fileName: $crmDatal'Id'],avatarTeyt. Scrnhatal"Name'1nemotelv cnoated att => Scnpatedhate$this->restoreAnyTrashedEntity(Sthis->config->contacts•, $crmData['Id']):/** @var Contact $contact */Scontact = Sthis-›config-›contacts()->update0rCreate(['crm_provider _id' => $crmDatal'Id']], $oSthis->handle0biectDeletion(Scontact, ScrmData):if (Scontact->trashedO) {Give me the lira ticket and the description* GinheritdocAD idMuuid (UUID)Ilo team_1dIo notifiable user 1d! providerI edition(• instance! is sandboxll versionШ installed_app_version! sync_metadatasync obiectsMauto sync activity= erm provider iderm base url#Olast_synced_at#leads_synced_atI accounts_synced_atI contacts sunced atШopportunities_synced_atIu contact roles svncedatil over_quota_at1 api disabled_at1• settingsopportunity assianment r! opportunity_max_value!opportunity_max_ageI opportunity value field! trigger assignment rulesstore transcrintI softohone override prospect *In defaul+ curnencvI created atI updated at32046ea38f7-c940-395d-9c79-0329d30e9ab7Jira Ticket:JY-XXXXX: Fix foreign key violation when matching activities to deleted Salesforce opportunitiesClassesFilesSymbolsActionsWhen an opportunity is deleted in Salesforce and the local deletion job runs, activities that belonged to that opportunity ared via the RematchActivity0nCrmObjectDetach listener. During this rematch, the system attempts to re-link theProject Files v OY@+ Calocforco/GorvicoToct© ServiceTest.php .../tests/Unit/Services/Activity/Salesforce/ServiceTest.phpC) ServiceTest.phn …/tests/Unit/Services/Crm/Salesforce/ServiceTest.oho.C SvncBatchRedisServiceTest.php …./tests/Unit/Services/Crm/Salesforce/SvncBatchRedisServiceTest.ohoC) SvncFieldsTraitTest.ohn .../tests/Unit/Services/Crm/Salesforce/ServiceTraits/SvncFieldsTraitTest.oho |dleObjectDeletion (because IsDeleted = true) |© FollowupActivityTraitTest.php .../tests/Unit/Services/Crm/Salesforce/ServiceTraits/FollowupActivityTraitTest.php(c)LoaActivitvtraitTest.oho/tests/Unit/Services/Crm/Salesforce/ServiceTraits/LoaActivitvTraitTest.onoleleted opportunity's ID is then used in Activity: :updateActivityCrmData to set activity.opportunity_id© BatchSyncTraitTest.php .../tests/Unit/Services/Crm/Salesforce/ServiceTraits/BatchSyncTraitTest.phpng a foreign key violation because the opportunity no longer exists in the database(c) RecordManioulationsTraitTest.oho .../tests/Unit/Services/Crm/Salesforce/ServiceTraits/RecordManioulationsTraitTest.oecks in importLead. importAccount. importOpportunitv.and importContact methods to return null it thedeleted oy nand leubjectbeletionhano levo ectvelecion sopportunaty, scrvata"oportunity→>trashed)) {urn null:Sopportunity:ants the foreian kev constraint violation bv ensurina deleted entities are not returned from the sunc processities will no longer tail with foreian key violations when rematching after opportunity deletionematch process will aracefully handle deleted Salesforce records bv returnina null instead of deleted entitv IDsaffects all Salesforce CRM entity types: Lead, Account, Opportunity, and ContactOpen In Riaht Solitnaed:• app/Services/Crm/Salesforce/Service.oho lines 1087-1089. 1224-1226. 1436-1438. 1604-1606)nl .2024-11-26 09:21:152026-05-22 09:48:43nublie function sunchrganizationd). voidf...?Ask anvthina (&4-L)2 ucadespublic function getInstalledAppVersion: ?string{...}« Code SWF-1.6Tihe Hunsnelinluain has been denrecated lfivouire not writinalin ungarian vou can safelvmninstalllHunsnellwithout affectina the diationaries for athez landuades /today 10:19)•0 1WN Windsurf Teamo1590-12UTE.8Po 4 spaces...
|
NULL
|
4482884375089409416
|
NULL
|
click
|
ocr
|
NULL
|
PhostormFV faVsco.jsVIewINavicarecodeKeractor°9 JY PhostormFV faVsco.jsVIewINavicarecodeKeractor°9 JY-20963-fix-im100% 52• Fri 22 May 13:23:14AskJiminnyReportActivityServiceTest vo [EMAIL] DeleteObiectstralt.pnp© DeleteOpportunityJob.phpT SoftDeletes.php= custom.log= laravel.log« SF jiminny@localhost]4 HS_local [jiminny@localhost]& console [PROD]& console [EU]CascadeMatcnActviycmmbata.png© CrmActivityService.php© RecordSelector.php© Activity.phpclass service excenas baseservice Inplementstii crm_configurations [EU] X A console [STAGING]411 4,130 v.3 V.21 ^1rowv 5OU+Y- WHERE id = 320Investigate Foreign KeTx: AutovDDLCSVL8=•URDER BY+O •c syncbatchkealsservice.ongD Traitsbaseclient.ono© BaseService.phpc) cachedcrmServiceDecorator.phpc) countrycodeResolver.phpCrmActivityProviderIntegratedEventListe 1576c) crmActivitvService.ohpc) crmcontiqurationSettinasService.oho© CrmobjectsResolver.php© DetaultProspectSearchStrategy.php© EmailHelper.php© FindsProspectinterface.php1571110/4157315741575157815861582C)LavoutManager.oho© MatchDomainByEmailInterface.php(c) OnnortunitvActivitvMatcher.nhnl© OpportunitySyncStrategyinterface.php© OpportunitySyncStrategyResolver.php1588© ProspectSearchScope.php© ProspectSearchStrategyFactory.php159€© ProspectSearchStrategyInterface.phpProviderkegistry.ong1592a RecordSelector.php* ResolveCompanyNameByEmailTrait.php 1594© TimePerioditerator.php> C Import1596> C Internal0 Kiosk1598v @ AutomatedReports1599© ActivityTypeService.php1600© Ask.JiminnvReportActivitvService.pho 1601@ AutomatedReportsCallbackService.ph 1602C) AutomatedReportsService.oho11603C) DealStadesService.oho1604C) RecioientsService.oho11685E) ReportSort.oho1606E) RenortSortDirection.oh11607(C) KioskService.oho1608> M Mail.16091M MeetinaGeneratorM NotificationI1611MoAutha1612M Plavbookc1613Tm PocalllA1614(D SecurityM StrateayM Streamine166A GU$profile = Sthis->getOwnerProfile( ownerld: $crmData('OwnerId'] ?? null):$data =team_id'account id'user 10name→ Sch1S->ceam->10,→ Saccount->10 !: nuLl.→ Sprotile:->user10,=> ScrmDatal 'OwnerId'] 22 nulll=> (ScrmData['Name'] ?2 null) !== null ? mb strimwidth(ScrmDatal'Nan=> ($crmData['Title'] ?? null) !== null ? mb_strimwidth($crmData['Ti= ScrmDatal'Emant' ?? nulu == nult ? mb strimwidth ScrmbatalEm= Scountrvcode.=> SparsedNumber['phone'] 22 null.mobnle nhone=> SmobileNumben'photo nath' => Sthis->orosnectPhotoPathService->get0rGeneratePhotoPathdcrmConfiguration: $this->config,crmProviderid: ScrmDatal'Id'],modelType: Contact::class,fileName: $crmDatal'Id'],avatarTeyt. Scrnhatal"Name'1nemotelv cnoated att => Scnpatedhate$this->restoreAnyTrashedEntity(Sthis->config->contacts•, $crmData['Id']):/** @var Contact $contact */Scontact = Sthis-›config-›contacts()->update0rCreate(['crm_provider _id' => $crmDatal'Id']], $oSthis->handle0biectDeletion(Scontact, ScrmData):if (Scontact->trashedO) {Give me the lira ticket and the description* GinheritdocAD idMuuid (UUID)Ilo team_1dIo notifiable user 1d! providerI edition(• instance! is sandboxll versionШ installed_app_version! sync_metadatasync obiectsMauto sync activity= erm provider iderm base url#Olast_synced_at#leads_synced_atI accounts_synced_atI contacts sunced atШopportunities_synced_atIu contact roles svncedatil over_quota_at1 api disabled_at1• settingsopportunity assianment r! opportunity_max_value!opportunity_max_ageI opportunity value field! trigger assignment rulesstore transcrintI softohone override prospect *In defaul+ curnencvI created atI updated at32046ea38f7-c940-395d-9c79-0329d30e9ab7Jira Ticket:JY-XXXXX: Fix foreign key violation when matching activities to deleted Salesforce opportunitiesClassesFilesSymbolsActionsWhen an opportunity is deleted in Salesforce and the local deletion job runs, activities that belonged to that opportunity ared via the RematchActivity0nCrmObjectDetach listener. During this rematch, the system attempts to re-link theProject Files v OY@+ Calocforco/GorvicoToct© ServiceTest.php .../tests/Unit/Services/Activity/Salesforce/ServiceTest.phpC) ServiceTest.phn …/tests/Unit/Services/Crm/Salesforce/ServiceTest.oho.C SvncBatchRedisServiceTest.php …./tests/Unit/Services/Crm/Salesforce/SvncBatchRedisServiceTest.ohoC) SvncFieldsTraitTest.ohn .../tests/Unit/Services/Crm/Salesforce/ServiceTraits/SvncFieldsTraitTest.oho |dleObjectDeletion (because IsDeleted = true) |© FollowupActivityTraitTest.php .../tests/Unit/Services/Crm/Salesforce/ServiceTraits/FollowupActivityTraitTest.php(c)LoaActivitvtraitTest.oho/tests/Unit/Services/Crm/Salesforce/ServiceTraits/LoaActivitvTraitTest.onoleleted opportunity's ID is then used in Activity: :updateActivityCrmData to set activity.opportunity_id© BatchSyncTraitTest.php .../tests/Unit/Services/Crm/Salesforce/ServiceTraits/BatchSyncTraitTest.phpng a foreign key violation because the opportunity no longer exists in the database(c) RecordManioulationsTraitTest.oho .../tests/Unit/Services/Crm/Salesforce/ServiceTraits/RecordManioulationsTraitTest.oecks in importLead. importAccount. importOpportunitv.and importContact methods to return null it thedeleted oy nand leubjectbeletionhano levo ectvelecion sopportunaty, scrvata"oportunity→>trashed)) {urn null:Sopportunity:ants the foreian kev constraint violation bv ensurina deleted entities are not returned from the sunc processities will no longer tail with foreian key violations when rematching after opportunity deletionematch process will aracefully handle deleted Salesforce records bv returnina null instead of deleted entitv IDsaffects all Salesforce CRM entity types: Lead, Account, Opportunity, and ContactOpen In Riaht Solitnaed:• app/Services/Crm/Salesforce/Service.oho lines 1087-1089. 1224-1226. 1436-1438. 1604-1606)nl .2024-11-26 09:21:152026-05-22 09:48:43nublie function sunchrganizationd). voidf...?Ask anvthina (&4-L)2 ucadespublic function getInstalledAppVersion: ?string{...}« Code SWF-1.6Tihe Hunsnelinluain has been denrecated lfivouire not writinalin ungarian vou can safelvmninstalllHunsnellwithout affectina the diationaries for athez landuades /today 10:19)•0 1WN Windsurf Teamo1590-12UTE.8Po 4 spaces...
|
70615
|
NULL
|
NULL
|
NULL
|